sciwrite-lint 0.2.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.
- sciwrite_lint/__init__.py +3 -0
- sciwrite_lint/__main__.py +527 -0
- sciwrite_lint/_network.py +195 -0
- sciwrite_lint/api.py +1484 -0
- sciwrite_lint/checks/__init__.py +1 -0
- sciwrite_lint/checks/_section_utils.py +111 -0
- sciwrite_lint/checks/cite_purpose.py +122 -0
- sciwrite_lint/checks/claim_support.py +96 -0
- sciwrite_lint/checks/cross_section_consistency.py +185 -0
- sciwrite_lint/checks/dangling_cite.py +93 -0
- sciwrite_lint/checks/dangling_ref.py +116 -0
- sciwrite_lint/checks/full_paper_consistency.py +604 -0
- sciwrite_lint/checks/ref_internal_checks.py +919 -0
- sciwrite_lint/checks/reference_accuracy.py +277 -0
- sciwrite_lint/checks/reference_exists.py +119 -0
- sciwrite_lint/checks/reference_unreliable.py +244 -0
- sciwrite_lint/checks/registry.py +136 -0
- sciwrite_lint/checks/retracted_cite.py +96 -0
- sciwrite_lint/checks/structure_promises.py +115 -0
- sciwrite_lint/checks/unreferenced_figure.py +70 -0
- sciwrite_lint/claims.py +330 -0
- sciwrite_lint/claude_backend.py +94 -0
- sciwrite_lint/claude_cli.py +405 -0
- sciwrite_lint/cli/__init__.py +1 -0
- sciwrite_lint/cli/check.py +480 -0
- sciwrite_lint/cli/config.py +229 -0
- sciwrite_lint/cli/fetch.py +250 -0
- sciwrite_lint/cli/misc.py +1202 -0
- sciwrite_lint/cli/rank.py +470 -0
- sciwrite_lint/cli/verify.py +437 -0
- sciwrite_lint/config.py +646 -0
- sciwrite_lint/cross_paper.py +174 -0
- sciwrite_lint/eval_claims.py +1196 -0
- sciwrite_lint/fulltext.py +851 -0
- sciwrite_lint/llm_utils.py +386 -0
- sciwrite_lint/local_pdfs.py +122 -0
- sciwrite_lint/manuscript_store.py +674 -0
- sciwrite_lint/models.py +139 -0
- sciwrite_lint/pdf/__init__.py +1 -0
- sciwrite_lint/pdf/grobid.py +785 -0
- sciwrite_lint/pdf/pdf_download.py +258 -0
- sciwrite_lint/pipeline.py +2694 -0
- sciwrite_lint/prompt_safety.py +30 -0
- sciwrite_lint/rate_limiter.py +227 -0
- sciwrite_lint/references/__init__.py +1 -0
- sciwrite_lint/references/citations.py +715 -0
- sciwrite_lint/references/crossref.py +282 -0
- sciwrite_lint/references/embedding_store.py +380 -0
- sciwrite_lint/references/matching.py +273 -0
- sciwrite_lint/references/metadata.py +273 -0
- sciwrite_lint/references/reference_store.py +823 -0
- sciwrite_lint/references/retraction_watch.py +178 -0
- sciwrite_lint/references/workspace_db.py +1390 -0
- sciwrite_lint/report.py +163 -0
- sciwrite_lint/schemas.py +260 -0
- sciwrite_lint/scoring/__init__.py +1 -0
- sciwrite_lint/scoring/chain.py +716 -0
- sciwrite_lint/scoring/contribution.py +322 -0
- sciwrite_lint/scoring/scilint_score.py +611 -0
- sciwrite_lint/tex_parser.py +248 -0
- sciwrite_lint/usage.py +594 -0
- sciwrite_lint/vision/__init__.py +1 -0
- sciwrite_lint/vision/cache.py +140 -0
- sciwrite_lint/vision/describe.py +311 -0
- sciwrite_lint/vision/image_extraction.py +491 -0
- sciwrite_lint/vision/pipeline.py +207 -0
- sciwrite_lint/vllm/__init__.py +1 -0
- sciwrite_lint/vllm/metrics.py +157 -0
- sciwrite_lint/vllm/vllm_server.py +445 -0
- sciwrite_lint/web.py +369 -0
- sciwrite_lint-0.2.0.dist-info/METADATA +268 -0
- sciwrite_lint-0.2.0.dist-info/RECORD +76 -0
- sciwrite_lint-0.2.0.dist-info/WHEEL +5 -0
- sciwrite_lint-0.2.0.dist-info/entry_points.txt +2 -0
- sciwrite_lint-0.2.0.dist-info/licenses/LICENSE +21 -0
- sciwrite_lint-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""vLLM Prometheus metrics fetching.
|
|
2
|
+
|
|
3
|
+
Parses the ``/metrics`` endpoint exposed by vLLM and returns a flat dict
|
|
4
|
+
of key–value pairs. Used by the CLI monitor (``sciwrite-lint vllm monitor``)
|
|
5
|
+
and the Streamlit UI dashboard.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
|
|
15
|
+
from loguru import logger
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _require_http_url(url: str) -> str:
|
|
19
|
+
"""Validate *url* uses http/https; raise ValueError otherwise.
|
|
20
|
+
|
|
21
|
+
Guards ``urlopen`` against file:// and other non-network schemes that
|
|
22
|
+
could be injected via a misconfigured endpoint.
|
|
23
|
+
"""
|
|
24
|
+
scheme = urllib.parse.urlparse(url).scheme
|
|
25
|
+
if scheme not in ("http", "https"):
|
|
26
|
+
raise ValueError(f"Refusing non-http(s) URL scheme: {scheme!r}")
|
|
27
|
+
return url
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Patterns: (regex, result-key). Each regex must have one capture group.
|
|
31
|
+
# Value pattern handles scientific notation (e.g. 1.572691e+06) from Prometheus.
|
|
32
|
+
_NUM = r"[\d.]+(?:[eE][+-]?\d+)?"
|
|
33
|
+
_GAUGE_PATTERNS: list[tuple[str, str]] = [
|
|
34
|
+
(rf"vllm:kv_cache_usage_perc\{{[^}}]*\}}\s+({_NUM})", "kv_cache_pct"),
|
|
35
|
+
(rf"vllm:num_requests_running\{{[^}}]*\}}\s+({_NUM})", "requests_running"),
|
|
36
|
+
(rf"vllm:num_requests_waiting\{{[^}}]*\}}\s+({_NUM})", "requests_waiting"),
|
|
37
|
+
(rf"vllm:num_requests_swapped\{{[^}}]*\}}\s+({_NUM})", "requests_swapped"),
|
|
38
|
+
(rf"vllm:prompt_tokens_total\{{[^}}]*\}}\s+({_NUM})", "prompt_tokens_total"),
|
|
39
|
+
(
|
|
40
|
+
rf"vllm:generation_tokens_total\{{[^}}]*\}}\s+({_NUM})",
|
|
41
|
+
"generation_tokens_total",
|
|
42
|
+
),
|
|
43
|
+
# Prefix cache (KV cache hit tracking)
|
|
44
|
+
(rf"vllm:prefix_cache_hits_total\{{[^}}]*\}}\s+({_NUM})", "prefix_cache_hits"),
|
|
45
|
+
(
|
|
46
|
+
rf"vllm:prefix_cache_queries_total\{{[^}}]*\}}\s+({_NUM})",
|
|
47
|
+
"prefix_cache_queries",
|
|
48
|
+
),
|
|
49
|
+
# Preemptions (KV evictions under memory pressure)
|
|
50
|
+
(rf"vllm:num_preemptions_total\{{[^}}]*\}}\s+({_NUM})", "num_preemptions"),
|
|
51
|
+
# Latency histograms (sum/count → averages)
|
|
52
|
+
(
|
|
53
|
+
rf"vllm:e2e_request_latency_seconds_sum\{{[^}}]*\}}\s+({_NUM})",
|
|
54
|
+
"e2e_latency_sum",
|
|
55
|
+
),
|
|
56
|
+
(
|
|
57
|
+
rf"vllm:e2e_request_latency_seconds_count\{{[^}}]*\}}\s+({_NUM})",
|
|
58
|
+
"e2e_latency_count",
|
|
59
|
+
),
|
|
60
|
+
(rf"vllm:time_to_first_token_seconds_sum\{{[^}}]*\}}\s+({_NUM})", "ttft_sum"),
|
|
61
|
+
(rf"vllm:time_to_first_token_seconds_count\{{[^}}]*\}}\s+({_NUM})", "ttft_count"),
|
|
62
|
+
(rf"vllm:inter_token_latency_seconds_sum\{{[^}}]*\}}\s+({_NUM})", "itl_sum"),
|
|
63
|
+
(rf"vllm:inter_token_latency_seconds_count\{{[^}}]*\}}\s+({_NUM})", "itl_count"),
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
_REQUEST_REASONS = ("stop", "length", "abort", "error")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def fetch_metrics(endpoint: str) -> dict[str, float | int | str]:
|
|
70
|
+
"""Fetch all vLLM metrics from Prometheus endpoint and ``/v1/models``.
|
|
71
|
+
|
|
72
|
+
*endpoint* is the OpenAI-compatible base, e.g. ``http://localhost:5001/v1``.
|
|
73
|
+
|
|
74
|
+
Returns a dict with keys like ``kv_cache_pct``, ``requests_running``,
|
|
75
|
+
``prefix_cache_hits``, ``e2e_latency_sum``, ``req_success_stop``, etc.
|
|
76
|
+
Returns an empty dict if the server is unreachable.
|
|
77
|
+
"""
|
|
78
|
+
base = endpoint.rstrip("/")
|
|
79
|
+
if base.endswith("/v1"):
|
|
80
|
+
base = base[:-3]
|
|
81
|
+
|
|
82
|
+
result: dict[str, float | int | str] = {}
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
url = _require_http_url(f"{base}/metrics")
|
|
86
|
+
# Scheme is validated by _require_http_url() above.
|
|
87
|
+
with urllib.request.urlopen(url, timeout=3) as resp: # nosec B310
|
|
88
|
+
text = resp.read().decode()
|
|
89
|
+
except Exception as e:
|
|
90
|
+
logger.debug(f"vLLM metrics fetch failed ({type(e).__name__}: {e})")
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
# Gauge and counter metrics
|
|
94
|
+
for pattern, key in _GAUGE_PATTERNS:
|
|
95
|
+
m = re.search(pattern, text)
|
|
96
|
+
if m:
|
|
97
|
+
result[key] = float(m.group(1))
|
|
98
|
+
|
|
99
|
+
# Request outcomes by finished_reason
|
|
100
|
+
for reason in _REQUEST_REASONS:
|
|
101
|
+
m = re.search(
|
|
102
|
+
rf'vllm:request_success_total\{{[^}}]*finished_reason="{reason}"[^}}]*\}}\s+([\d.]+)',
|
|
103
|
+
text,
|
|
104
|
+
)
|
|
105
|
+
if m:
|
|
106
|
+
result[f"req_success_{reason}"] = float(m.group(1))
|
|
107
|
+
|
|
108
|
+
# Label-embedded config values
|
|
109
|
+
m = re.search(r'num_gpu_blocks="(\d+)"', text)
|
|
110
|
+
if m:
|
|
111
|
+
result["num_gpu_blocks"] = int(m.group(1))
|
|
112
|
+
m = re.search(r'block_size="(\d+)"', text)
|
|
113
|
+
if m:
|
|
114
|
+
result["block_size"] = int(m.group(1))
|
|
115
|
+
m = re.search(r'gpu_memory_utilization="([\d.]+)"', text)
|
|
116
|
+
if m:
|
|
117
|
+
result["gpu_util_cap"] = float(m.group(1))
|
|
118
|
+
|
|
119
|
+
# max_model_len from models API
|
|
120
|
+
try:
|
|
121
|
+
url = _require_http_url(f"{endpoint}/models")
|
|
122
|
+
# Scheme is validated by _require_http_url() above.
|
|
123
|
+
with urllib.request.urlopen(url, timeout=3) as resp: # nosec B310
|
|
124
|
+
data = json.loads(resp.read().decode())
|
|
125
|
+
for model in data.get("data", []):
|
|
126
|
+
if "max_model_len" in model:
|
|
127
|
+
result["max_seq"] = model["max_model_len"]
|
|
128
|
+
break
|
|
129
|
+
except Exception as e:
|
|
130
|
+
logger.debug(f"vLLM /models fetch failed ({type(e).__name__}: {e})")
|
|
131
|
+
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def fetch_metrics_summary(endpoint: str) -> str | None:
|
|
136
|
+
"""One-line summary string for ``containers status``.
|
|
137
|
+
|
|
138
|
+
Returns e.g. ``"max_seq: 20,000, KV cache: 0.3% used, 7145 blocks"``
|
|
139
|
+
or ``None`` on failure.
|
|
140
|
+
"""
|
|
141
|
+
m = fetch_metrics(endpoint)
|
|
142
|
+
if not m:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
info: dict[str, str] = {}
|
|
146
|
+
if "max_seq" in m:
|
|
147
|
+
info["max_seq"] = f"{m['max_seq']:,}"
|
|
148
|
+
if "kv_cache_pct" in m:
|
|
149
|
+
pct = m["kv_cache_pct"]
|
|
150
|
+
assert isinstance(pct, float)
|
|
151
|
+
info["KV cache"] = f"{pct:.1%} used"
|
|
152
|
+
if "num_gpu_blocks" in m:
|
|
153
|
+
info["KV blocks"] = str(m["num_gpu_blocks"])
|
|
154
|
+
if "gpu_util_cap" in m:
|
|
155
|
+
info["GPU util cap"] = str(m["gpu_util_cap"])
|
|
156
|
+
|
|
157
|
+
return ", ".join(f"{k}: {v}" for k, v in info.items()) if info else None
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""vLLM server management: start, stop, status.
|
|
2
|
+
|
|
3
|
+
Runs vLLM in a container (podman/docker). Just start/stop/status.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import shutil
|
|
11
|
+
import signal
|
|
12
|
+
import socket
|
|
13
|
+
import subprocess
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from loguru import logger
|
|
17
|
+
from sciwrite_lint.config import LintConfig
|
|
18
|
+
|
|
19
|
+
# Persistent state
|
|
20
|
+
_STATE_DIR = Path.home() / ".sciwrite-lint"
|
|
21
|
+
_LAST_PORTS_FILE = _STATE_DIR / "last_ports.json"
|
|
22
|
+
|
|
23
|
+
# Model profiles: HuggingFace model ID, served name, and config
|
|
24
|
+
MODELS: dict[str, dict] = {
|
|
25
|
+
"qwen3": {
|
|
26
|
+
"hf_model": "RedHatAI/Qwen3-8B-FP8-dynamic",
|
|
27
|
+
"served_name": "qwen3-8b-fp8",
|
|
28
|
+
"reasoning_parser": "qwen3", # server-side <think> parsing
|
|
29
|
+
},
|
|
30
|
+
"gemma3": {
|
|
31
|
+
"hf_model": "RedHatAI/gemma-3-12b-it-FP8-dynamic",
|
|
32
|
+
"served_name": "gemma3-12b-fp8",
|
|
33
|
+
"reasoning_parser": "", # no thinking support
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Default vLLM flags for consumer-GPU deployment
|
|
38
|
+
_DEFAULT_GPU_MEM = 0.9
|
|
39
|
+
_DEFAULT_MAX_MODEL_LEN = 40_960
|
|
40
|
+
_DEFAULT_PORT = 5001
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Port management
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _load_last_ports() -> dict[str, int]:
|
|
49
|
+
"""Load remembered ports from ~/.sciwrite-lint/last_ports.json."""
|
|
50
|
+
try:
|
|
51
|
+
return json.loads(_LAST_PORTS_FILE.read_text()) # type: ignore[no-any-return]
|
|
52
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
53
|
+
return {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _save_last_port(service: str, port: int) -> None:
|
|
57
|
+
"""Remember which port a service last used."""
|
|
58
|
+
ports = _load_last_ports()
|
|
59
|
+
if ports.get(service) == port:
|
|
60
|
+
return
|
|
61
|
+
ports[service] = port
|
|
62
|
+
try:
|
|
63
|
+
_STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
_LAST_PORTS_FILE.write_text(json.dumps(ports, indent=2) + "\n")
|
|
65
|
+
except OSError:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _port_available(host: str, port: int) -> bool:
|
|
70
|
+
"""Check whether port can be bound."""
|
|
71
|
+
try:
|
|
72
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
73
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
74
|
+
s.bind((host, port))
|
|
75
|
+
return True
|
|
76
|
+
except OSError:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _resolve_port(config: LintConfig) -> int:
|
|
81
|
+
"""Resolve vLLM port: config endpoint > remembered > default."""
|
|
82
|
+
# Parse port from config endpoint
|
|
83
|
+
from urllib.parse import urlparse
|
|
84
|
+
|
|
85
|
+
parsed = urlparse(config.llm_endpoint)
|
|
86
|
+
config_port = parsed.port or _DEFAULT_PORT
|
|
87
|
+
|
|
88
|
+
# Try remembered port
|
|
89
|
+
last = _load_last_ports().get("vllm")
|
|
90
|
+
if last is not None and last != config_port and _port_available("0.0.0.0", last):
|
|
91
|
+
return last
|
|
92
|
+
return config_port
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Process management
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def _check_api_health(endpoint: str) -> dict | None:
|
|
101
|
+
"""Check if vLLM API is responding. Returns model list or None."""
|
|
102
|
+
try:
|
|
103
|
+
import httpx
|
|
104
|
+
|
|
105
|
+
async with httpx.AsyncClient() as client:
|
|
106
|
+
resp = await client.get(f"{endpoint}/models", timeout=5.0)
|
|
107
|
+
if resp.status_code == 200:
|
|
108
|
+
return resp.json() # type: ignore[no-any-return]
|
|
109
|
+
except httpx.HTTPError:
|
|
110
|
+
pass
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Container helpers
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _detect_container_runtime() -> str | None:
|
|
120
|
+
"""Detect available container runtime (podman preferred)."""
|
|
121
|
+
for rt in ("podman", "docker"):
|
|
122
|
+
if shutil.which(rt):
|
|
123
|
+
return rt
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _container_name(model: str) -> str:
|
|
128
|
+
return f"sciwrite-lint-vllm-{model}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _container_exists(runtime: str, name: str) -> bool:
|
|
132
|
+
result = subprocess.run(
|
|
133
|
+
[runtime, "container", "inspect", name],
|
|
134
|
+
capture_output=True,
|
|
135
|
+
)
|
|
136
|
+
return result.returncode == 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _container_running(runtime: str, name: str) -> bool:
|
|
140
|
+
result = subprocess.run(
|
|
141
|
+
[runtime, "inspect", "--format", "{{.State.Running}}", name],
|
|
142
|
+
capture_output=True,
|
|
143
|
+
text=True,
|
|
144
|
+
)
|
|
145
|
+
return result.returncode == 0 and result.stdout.strip() == "true"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# Containerized vLLM
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def start_container(
|
|
154
|
+
config: LintConfig,
|
|
155
|
+
model: str | None = None,
|
|
156
|
+
pull: bool = False,
|
|
157
|
+
) -> int:
|
|
158
|
+
"""Start vLLM in a container (podman/docker)."""
|
|
159
|
+
runtime = _detect_container_runtime()
|
|
160
|
+
if not runtime:
|
|
161
|
+
logger.error("Neither podman nor docker found on PATH")
|
|
162
|
+
return 1
|
|
163
|
+
|
|
164
|
+
model_name = model or config.llm_model
|
|
165
|
+
profile = MODELS.get(model_name)
|
|
166
|
+
if not profile:
|
|
167
|
+
logger.error(f"Unknown model '{model_name}'. Available: {', '.join(MODELS)}")
|
|
168
|
+
return 1
|
|
169
|
+
|
|
170
|
+
name = _container_name(model_name)
|
|
171
|
+
serve_port = _resolve_port(config)
|
|
172
|
+
|
|
173
|
+
# Already running?
|
|
174
|
+
if _container_running(runtime, name):
|
|
175
|
+
logger.info(f"Container '{name}' is already running")
|
|
176
|
+
logger.info("Use 'sciwrite-lint vllm restart' to restart")
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
# Remove stopped container (config may have changed)
|
|
180
|
+
if _container_exists(runtime, name):
|
|
181
|
+
logger.info(f"Removing stopped container '{name}'")
|
|
182
|
+
subprocess.run([runtime, "rm", "-f", name], capture_output=True)
|
|
183
|
+
|
|
184
|
+
# Pull latest image
|
|
185
|
+
image = config.vllm_image
|
|
186
|
+
if pull:
|
|
187
|
+
logger.info(f"Pulling {image}")
|
|
188
|
+
pull_result = subprocess.run([runtime, "pull", image])
|
|
189
|
+
if pull_result.returncode != 0:
|
|
190
|
+
logger.error(f"Failed to pull image: {image}")
|
|
191
|
+
return 1
|
|
192
|
+
|
|
193
|
+
cmd = [
|
|
194
|
+
runtime,
|
|
195
|
+
"run",
|
|
196
|
+
"-d",
|
|
197
|
+
"--name",
|
|
198
|
+
name,
|
|
199
|
+
"--device",
|
|
200
|
+
"nvidia.com/gpu=all",
|
|
201
|
+
"--memory",
|
|
202
|
+
config.vllm_memory,
|
|
203
|
+
"-p",
|
|
204
|
+
f"{serve_port}:8000",
|
|
205
|
+
"-v",
|
|
206
|
+
f"{Path.home() / '.cache' / 'huggingface'}:/root/.cache/huggingface",
|
|
207
|
+
"--restart",
|
|
208
|
+
"unless-stopped",
|
|
209
|
+
image,
|
|
210
|
+
profile["hf_model"], # positional arg (--model deprecated in vLLM v0.13)
|
|
211
|
+
"--host",
|
|
212
|
+
"0.0.0.0",
|
|
213
|
+
"--port",
|
|
214
|
+
"8000",
|
|
215
|
+
"--served-model-name",
|
|
216
|
+
profile["served_name"],
|
|
217
|
+
"--trust-remote-code",
|
|
218
|
+
"--gpu-memory-utilization",
|
|
219
|
+
str(_DEFAULT_GPU_MEM),
|
|
220
|
+
"--max-model-len",
|
|
221
|
+
str(_DEFAULT_MAX_MODEL_LEN),
|
|
222
|
+
"--kv-cache-dtype",
|
|
223
|
+
"fp8",
|
|
224
|
+
"--enable-chunked-prefill",
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
# Server-side reasoning parser for thinking models (Qwen3)
|
|
228
|
+
if profile.get("reasoning_parser"):
|
|
229
|
+
cmd.extend(["--reasoning-parser", profile["reasoning_parser"]])
|
|
230
|
+
|
|
231
|
+
logger.info(f"Starting vLLM container '{name}'")
|
|
232
|
+
logger.info(f"Runtime: {runtime}")
|
|
233
|
+
logger.info(f"Image: {image}")
|
|
234
|
+
logger.info(f"Model: {profile['hf_model']}")
|
|
235
|
+
logger.info(f"Served as: {profile['served_name']}")
|
|
236
|
+
logger.info(f"Port: {serve_port}")
|
|
237
|
+
logger.info(f"GPU memory: {_DEFAULT_GPU_MEM}")
|
|
238
|
+
|
|
239
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
240
|
+
if result.returncode != 0:
|
|
241
|
+
stderr = result.stderr.strip()
|
|
242
|
+
if "CDI" in stderr or "unresolvable" in stderr:
|
|
243
|
+
logger.error(
|
|
244
|
+
"GPU passthrough not configured (CDI spec missing).\n\n"
|
|
245
|
+
"Fix with:\n"
|
|
246
|
+
" sudo apt install nvidia-container-toolkit\n"
|
|
247
|
+
" sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml\n"
|
|
248
|
+
" podman run --rm --device nvidia.com/gpu=all ubuntu nvidia-smi"
|
|
249
|
+
)
|
|
250
|
+
else:
|
|
251
|
+
logger.error(f"Failed to start container:\n{stderr}")
|
|
252
|
+
return 1
|
|
253
|
+
|
|
254
|
+
container_id = result.stdout.strip()[:12]
|
|
255
|
+
logger.info(f"Container started: {container_id}")
|
|
256
|
+
logger.info(f"API will be available at http://localhost:{serve_port}/v1")
|
|
257
|
+
|
|
258
|
+
# Check if model is cached
|
|
259
|
+
hf_cache = Path.home() / ".cache" / "huggingface" / "hub"
|
|
260
|
+
model_cache_name = f"models--{profile['hf_model'].replace('/', '--')}"
|
|
261
|
+
if (hf_cache / model_cache_name).exists():
|
|
262
|
+
logger.info("Model weights cached. Loading into GPU (~30-60s)")
|
|
263
|
+
else:
|
|
264
|
+
logger.info(
|
|
265
|
+
"First run: downloading model weights. This may take several minutes"
|
|
266
|
+
)
|
|
267
|
+
logger.info(f"Check logs: {runtime} logs -f {name}")
|
|
268
|
+
logger.info("Check status: sciwrite-lint vllm status")
|
|
269
|
+
|
|
270
|
+
_save_last_port("vllm", serve_port)
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def stop_container(config: LintConfig, model: str | None = None) -> int:
|
|
275
|
+
"""Stop the vLLM container."""
|
|
276
|
+
runtime = _detect_container_runtime()
|
|
277
|
+
if not runtime:
|
|
278
|
+
logger.error("Neither podman nor docker found on PATH")
|
|
279
|
+
return 1
|
|
280
|
+
|
|
281
|
+
model_name = model or config.llm_model
|
|
282
|
+
name = _container_name(model_name)
|
|
283
|
+
|
|
284
|
+
if not _container_exists(runtime, name):
|
|
285
|
+
logger.info(f"Container '{name}' does not exist")
|
|
286
|
+
return 0
|
|
287
|
+
|
|
288
|
+
if not _container_running(runtime, name):
|
|
289
|
+
logger.info(f"Container '{name}' is not running")
|
|
290
|
+
return 0
|
|
291
|
+
|
|
292
|
+
logger.info(f"Stopping container '{name}'")
|
|
293
|
+
result = subprocess.run([runtime, "stop", name], capture_output=True, text=True)
|
|
294
|
+
if result.returncode != 0:
|
|
295
|
+
logger.error(f"Failed to stop container:\n{result.stderr.strip()}")
|
|
296
|
+
return 1
|
|
297
|
+
logger.info("Container stopped. GPU memory released")
|
|
298
|
+
return 0
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def container_status(config: LintConfig, model: str | None = None) -> int:
|
|
302
|
+
"""Show vLLM container status and API health."""
|
|
303
|
+
runtime = _detect_container_runtime()
|
|
304
|
+
if not runtime:
|
|
305
|
+
logger.error("Neither podman nor docker found on PATH")
|
|
306
|
+
return 1
|
|
307
|
+
|
|
308
|
+
model_name = model or config.llm_model
|
|
309
|
+
name = _container_name(model_name)
|
|
310
|
+
|
|
311
|
+
if not _container_exists(runtime, name):
|
|
312
|
+
logger.info(f"Container '{name}' does not exist")
|
|
313
|
+
logger.info("Start it with: sciwrite-lint containers start")
|
|
314
|
+
return 0
|
|
315
|
+
|
|
316
|
+
inspect_result = subprocess.run(
|
|
317
|
+
[runtime, "inspect", "--format", "{{.State.Status}}", name],
|
|
318
|
+
capture_output=True,
|
|
319
|
+
text=True,
|
|
320
|
+
)
|
|
321
|
+
state = (
|
|
322
|
+
inspect_result.stdout.strip() if inspect_result.returncode == 0 else "unknown"
|
|
323
|
+
)
|
|
324
|
+
logger.info(f"Container: {name}")
|
|
325
|
+
logger.info(f"State: {state}")
|
|
326
|
+
|
|
327
|
+
if state != "running":
|
|
328
|
+
logger.info("API: not available (container not running)")
|
|
329
|
+
return 0
|
|
330
|
+
|
|
331
|
+
# API health
|
|
332
|
+
health = asyncio.run(_check_api_health(config.llm_endpoint))
|
|
333
|
+
if health:
|
|
334
|
+
models = [m["id"] for m in health.get("data", [])]
|
|
335
|
+
logger.info(f"API: ready (models: {', '.join(models)})")
|
|
336
|
+
else:
|
|
337
|
+
logger.info(f"API: loading (not responding yet at {config.llm_endpoint})")
|
|
338
|
+
|
|
339
|
+
return 0
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def container_logs(
|
|
343
|
+
config: LintConfig,
|
|
344
|
+
model: str | None = None,
|
|
345
|
+
follow: bool = False,
|
|
346
|
+
tail: int = 50,
|
|
347
|
+
) -> int:
|
|
348
|
+
"""Show vLLM container logs."""
|
|
349
|
+
runtime = _detect_container_runtime()
|
|
350
|
+
if not runtime:
|
|
351
|
+
logger.error("Neither podman nor docker found on PATH")
|
|
352
|
+
return 1
|
|
353
|
+
|
|
354
|
+
model_name = model or config.llm_model
|
|
355
|
+
name = _container_name(model_name)
|
|
356
|
+
|
|
357
|
+
if not _container_exists(runtime, name):
|
|
358
|
+
logger.error(f"Container '{name}' does not exist")
|
|
359
|
+
return 1
|
|
360
|
+
|
|
361
|
+
cmd = [runtime, "logs", "--tail", str(tail)]
|
|
362
|
+
if follow:
|
|
363
|
+
cmd.append("-f")
|
|
364
|
+
cmd.append(name)
|
|
365
|
+
|
|
366
|
+
proc = subprocess.Popen(cmd)
|
|
367
|
+
try:
|
|
368
|
+
proc.wait()
|
|
369
|
+
except KeyboardInterrupt:
|
|
370
|
+
proc.send_signal(signal.SIGINT)
|
|
371
|
+
proc.wait()
|
|
372
|
+
return 0
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def remove_container(
|
|
376
|
+
config: LintConfig,
|
|
377
|
+
model: str | None = None,
|
|
378
|
+
force: bool = False,
|
|
379
|
+
) -> int:
|
|
380
|
+
"""Remove the vLLM container."""
|
|
381
|
+
runtime = _detect_container_runtime()
|
|
382
|
+
if not runtime:
|
|
383
|
+
logger.error("Neither podman nor docker found on PATH")
|
|
384
|
+
return 1
|
|
385
|
+
|
|
386
|
+
model_name = model or config.llm_model
|
|
387
|
+
name = _container_name(model_name)
|
|
388
|
+
|
|
389
|
+
if not _container_exists(runtime, name):
|
|
390
|
+
logger.info(f"Container '{name}' does not exist")
|
|
391
|
+
return 0
|
|
392
|
+
|
|
393
|
+
if _container_running(runtime, name) and not force:
|
|
394
|
+
logger.info(f"Container '{name}' is still running")
|
|
395
|
+
logger.info("Stop it first with 'sciwrite-lint vllm stop', or use --force")
|
|
396
|
+
return 1
|
|
397
|
+
|
|
398
|
+
cmd = [runtime, "rm"]
|
|
399
|
+
if force:
|
|
400
|
+
cmd.append("-f")
|
|
401
|
+
cmd.append(name)
|
|
402
|
+
|
|
403
|
+
logger.info(f"Removing container '{name}'")
|
|
404
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
405
|
+
if result.returncode != 0:
|
|
406
|
+
logger.error(f"Failed to remove container:\n{result.stderr.strip()}")
|
|
407
|
+
return 1
|
|
408
|
+
logger.info("Container removed")
|
|
409
|
+
return 0
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ---------------------------------------------------------------------------
|
|
413
|
+
# Status (unified — checks both native and container)
|
|
414
|
+
# ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def status(config: LintConfig) -> int:
|
|
418
|
+
"""Show vLLM status: check API health regardless of how it was started."""
|
|
419
|
+
endpoint = config.llm_endpoint
|
|
420
|
+
logger.info(f"vLLM endpoint: {endpoint}")
|
|
421
|
+
|
|
422
|
+
# Check API
|
|
423
|
+
health = asyncio.run(_check_api_health(endpoint))
|
|
424
|
+
if health:
|
|
425
|
+
models = [m["id"] for m in health.get("data", [])]
|
|
426
|
+
logger.info("Status: ready")
|
|
427
|
+
logger.info(f"Models: {', '.join(models)}")
|
|
428
|
+
else:
|
|
429
|
+
logger.info("Status: not responding")
|
|
430
|
+
logger.info("Start with: sciwrite-lint containers start")
|
|
431
|
+
|
|
432
|
+
# Check for running containers
|
|
433
|
+
runtime = _detect_container_runtime()
|
|
434
|
+
if runtime:
|
|
435
|
+
for model_name in MODELS:
|
|
436
|
+
name = _container_name(model_name)
|
|
437
|
+
if _container_running(runtime, name):
|
|
438
|
+
logger.info(f"Container '{name}' is running ({runtime})")
|
|
439
|
+
|
|
440
|
+
# Check for native process
|
|
441
|
+
result = subprocess.run(["pgrep", "-f", "vllm serve"], capture_output=True)
|
|
442
|
+
if result.returncode == 0:
|
|
443
|
+
logger.info("Native vLLM process detected")
|
|
444
|
+
|
|
445
|
+
return 0
|