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.
Files changed (76) hide show
  1. sciwrite_lint/__init__.py +3 -0
  2. sciwrite_lint/__main__.py +527 -0
  3. sciwrite_lint/_network.py +195 -0
  4. sciwrite_lint/api.py +1484 -0
  5. sciwrite_lint/checks/__init__.py +1 -0
  6. sciwrite_lint/checks/_section_utils.py +111 -0
  7. sciwrite_lint/checks/cite_purpose.py +122 -0
  8. sciwrite_lint/checks/claim_support.py +96 -0
  9. sciwrite_lint/checks/cross_section_consistency.py +185 -0
  10. sciwrite_lint/checks/dangling_cite.py +93 -0
  11. sciwrite_lint/checks/dangling_ref.py +116 -0
  12. sciwrite_lint/checks/full_paper_consistency.py +604 -0
  13. sciwrite_lint/checks/ref_internal_checks.py +919 -0
  14. sciwrite_lint/checks/reference_accuracy.py +277 -0
  15. sciwrite_lint/checks/reference_exists.py +119 -0
  16. sciwrite_lint/checks/reference_unreliable.py +244 -0
  17. sciwrite_lint/checks/registry.py +136 -0
  18. sciwrite_lint/checks/retracted_cite.py +96 -0
  19. sciwrite_lint/checks/structure_promises.py +115 -0
  20. sciwrite_lint/checks/unreferenced_figure.py +70 -0
  21. sciwrite_lint/claims.py +330 -0
  22. sciwrite_lint/claude_backend.py +94 -0
  23. sciwrite_lint/claude_cli.py +405 -0
  24. sciwrite_lint/cli/__init__.py +1 -0
  25. sciwrite_lint/cli/check.py +480 -0
  26. sciwrite_lint/cli/config.py +229 -0
  27. sciwrite_lint/cli/fetch.py +250 -0
  28. sciwrite_lint/cli/misc.py +1202 -0
  29. sciwrite_lint/cli/rank.py +470 -0
  30. sciwrite_lint/cli/verify.py +437 -0
  31. sciwrite_lint/config.py +646 -0
  32. sciwrite_lint/cross_paper.py +174 -0
  33. sciwrite_lint/eval_claims.py +1196 -0
  34. sciwrite_lint/fulltext.py +851 -0
  35. sciwrite_lint/llm_utils.py +386 -0
  36. sciwrite_lint/local_pdfs.py +122 -0
  37. sciwrite_lint/manuscript_store.py +674 -0
  38. sciwrite_lint/models.py +139 -0
  39. sciwrite_lint/pdf/__init__.py +1 -0
  40. sciwrite_lint/pdf/grobid.py +785 -0
  41. sciwrite_lint/pdf/pdf_download.py +258 -0
  42. sciwrite_lint/pipeline.py +2694 -0
  43. sciwrite_lint/prompt_safety.py +30 -0
  44. sciwrite_lint/rate_limiter.py +227 -0
  45. sciwrite_lint/references/__init__.py +1 -0
  46. sciwrite_lint/references/citations.py +715 -0
  47. sciwrite_lint/references/crossref.py +282 -0
  48. sciwrite_lint/references/embedding_store.py +380 -0
  49. sciwrite_lint/references/matching.py +273 -0
  50. sciwrite_lint/references/metadata.py +273 -0
  51. sciwrite_lint/references/reference_store.py +823 -0
  52. sciwrite_lint/references/retraction_watch.py +178 -0
  53. sciwrite_lint/references/workspace_db.py +1390 -0
  54. sciwrite_lint/report.py +163 -0
  55. sciwrite_lint/schemas.py +260 -0
  56. sciwrite_lint/scoring/__init__.py +1 -0
  57. sciwrite_lint/scoring/chain.py +716 -0
  58. sciwrite_lint/scoring/contribution.py +322 -0
  59. sciwrite_lint/scoring/scilint_score.py +611 -0
  60. sciwrite_lint/tex_parser.py +248 -0
  61. sciwrite_lint/usage.py +594 -0
  62. sciwrite_lint/vision/__init__.py +1 -0
  63. sciwrite_lint/vision/cache.py +140 -0
  64. sciwrite_lint/vision/describe.py +311 -0
  65. sciwrite_lint/vision/image_extraction.py +491 -0
  66. sciwrite_lint/vision/pipeline.py +207 -0
  67. sciwrite_lint/vllm/__init__.py +1 -0
  68. sciwrite_lint/vllm/metrics.py +157 -0
  69. sciwrite_lint/vllm/vllm_server.py +445 -0
  70. sciwrite_lint/web.py +369 -0
  71. sciwrite_lint-0.2.0.dist-info/METADATA +268 -0
  72. sciwrite_lint-0.2.0.dist-info/RECORD +76 -0
  73. sciwrite_lint-0.2.0.dist-info/WHEEL +5 -0
  74. sciwrite_lint-0.2.0.dist-info/entry_points.txt +2 -0
  75. sciwrite_lint-0.2.0.dist-info/licenses/LICENSE +21 -0
  76. 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