captchakraken 2.9.0 → 3.0.0

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 (77) hide show
  1. package/AGENTS.md +112 -0
  2. package/NOTICE +36 -0
  3. package/dist/cli-invocation.d.ts +7 -36
  4. package/dist/cli-invocation.js +8 -49
  5. package/dist/errors.d.ts +7 -27
  6. package/dist/errors.js +6 -25
  7. package/dist/humanize.d.ts +56 -240
  8. package/dist/humanize.js +159 -415
  9. package/dist/index.d.ts +5 -129
  10. package/dist/index.js +4 -98
  11. package/dist/kinds.d.ts +135 -0
  12. package/dist/kinds.js +70 -0
  13. package/dist/limits.d.ts +0 -48
  14. package/dist/limits.js +2 -48
  15. package/dist/model-name.d.ts +4 -19
  16. package/dist/model-name.js +32 -40
  17. package/dist/playwright-types.d.ts +25 -95
  18. package/dist/playwright-types.js +0 -23
  19. package/dist/puppeteer-adapter.d.ts +4 -43
  20. package/dist/puppeteer-adapter.js +22 -58
  21. package/dist/python-command.d.ts +0 -41
  22. package/dist/python-command.js +0 -37
  23. package/dist/selectors.d.ts +54 -0
  24. package/dist/selectors.js +117 -0
  25. package/dist/slide-geometry.d.ts +2 -29
  26. package/dist/slide-geometry.js +18 -44
  27. package/dist/solver.d.ts +94 -712
  28. package/dist/solver.js +923 -3362
  29. package/dist/timing.d.ts +7 -40
  30. package/dist/timing.js +4 -36
  31. package/dist/token-usage.d.ts +1 -7
  32. package/dist/token-usage.js +10 -95
  33. package/dist/types.d.ts +75 -493
  34. package/dist/watcher.d.ts +5 -78
  35. package/dist/watcher.js +0 -56
  36. package/package.json +11 -4
  37. package/python/AGENTS.md +122 -0
  38. package/python/NOTICE +36 -0
  39. package/python/README.md +1 -2
  40. package/python/examples/README.md +10 -0
  41. package/python/examples/_harness.py +4 -73
  42. package/python/examples/demoHcaptcha.py +0 -13
  43. package/python/examples/demoRecaptcha.py +0 -10
  44. package/python/examples/watch_playwright.py +1 -26
  45. package/python/examples/with_local_gguf.py +28 -0
  46. package/python/examples/with_playwright.py +0 -19
  47. package/python/pyproject.toml +68 -19
  48. package/python/src/captchakraken/__init__.py +37 -45
  49. package/python/src/captchakraken/action_types.py +14 -36
  50. package/python/src/captchakraken/cli.py +262 -883
  51. package/python/src/captchakraken/config.py +60 -146
  52. package/python/src/captchakraken/errors.py +17 -95
  53. package/python/src/captchakraken/humanize.py +174 -597
  54. package/python/src/captchakraken/image_processor.py +16 -235
  55. package/python/src/captchakraken/keyframes.py +46 -484
  56. package/python/src/captchakraken/kinds.py +152 -0
  57. package/python/src/captchakraken/models.json +36 -4
  58. package/python/src/captchakraken/overlay.py +65 -498
  59. package/python/src/captchakraken/page_solver.py +1052 -3098
  60. package/python/src/captchakraken/planner.py +50 -487
  61. package/python/src/captchakraken/prompts.py +45 -261
  62. package/python/src/captchakraken/selectors.py +184 -0
  63. package/python/src/captchakraken/server_manager.py +7 -58
  64. package/python/src/captchakraken/solver.py +128 -522
  65. package/python/src/captchakraken/timing.py +20 -74
  66. package/python/src/captchakraken/tool_calls/board_painted.py +47 -0
  67. package/python/src/captchakraken/tool_calls/find_checkbox.py +20 -30
  68. package/python/src/captchakraken/tool_calls/find_grid.py +186 -1339
  69. package/python/src/captchakraken/tool_calls/track_piece.py +81 -57
  70. package/python/src/captchakraken/updater.py +7 -76
  71. package/python/src/captchakraken/watcher.py +12 -101
  72. package/scripts/copy-python.mjs +10 -10
  73. package/scripts/setup-python.js +1 -21
  74. package/dist/trajectory.d.ts +0 -58
  75. package/dist/trajectory.js +0 -266
  76. package/python/src/captchakraken/tool_calls/move_indicator.py +0 -431
  77. package/python/src/captchakraken/trajectory.py +0 -284
@@ -0,0 +1,184 @@
1
+ """Every selector the driver knows, keyed by vendor. Mirrored in js/src/selectors.ts; keep both in the same order,
2
+ because detection takes the first visible match in table order."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, fields
7
+ from typing import Mapping, NamedTuple, Optional, Sequence, Tuple
8
+
9
+ from .kinds import FrameRole, RecaptchaBanner, Vendor
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class VendorSelectors:
14
+ # URL substrings of the vendor's code on the wire. A tripwire, not a detector: the host stays on the wire when a
15
+ # vendor renames its markup (see TRIBAL_KNOWLEDGE.md). BotDetect is self-hosted, so it has none.
16
+ hosts: Sequence[str]
17
+ # On the host page: the open challenge. Detected first.
18
+ challenge: Sequence[str] = ()
19
+ # On the host page: the checkbox. Detected while `response` is empty and `checked` is not showing.
20
+ checkbox: Sequence[str] = ()
21
+ # On the host page: inline widget shapes, detected last. Iframe selectors go first; a class that lives inside the
22
+ # frame document (.CheckboxCaptcha, .mtcap) only ever matches an inline embed.
23
+ widget: Sequence[str] = ()
24
+ # On the host page: the field the vendor fills on success. Read first and unconditionally, because hCaptcha's
25
+ # overlay hides the anchor.
26
+ response: Optional[str] = None
27
+ # Inside the checkbox frame: the box shows accepted. Demo pages do not always fill `response`.
28
+ checked: Optional[str] = None
29
+ # On the host page: the vendor painted its success state. Visibility is part of the test (see TRIBAL_KNOWLEDGE.md).
30
+ accepted: Optional[str] = None
31
+ # Inside the challenge frame: a new round has painted, with text.
32
+ fresh: Optional[str] = None
33
+ # Inside the challenge frame: the pictures a board is made of; the driver waits for each to have painted.
34
+ images: Sequence[str] = ()
35
+ # Inside the widget: the vendor's own submit control, tried after the generic text probes.
36
+ submit: Sequence[str] = ()
37
+ # Inside the challenge frame: verdict banners and what each means.
38
+ banners: Sequence[Tuple[str, RecaptchaBanner]] = ()
39
+ # Inside the widget, and its enclosing fieldset/form: the answer box of a typed captcha.
40
+ text_input: Sequence[str] = ()
41
+ # Inside the widget: the knob a slide must start on; a drag from the piece moves nothing.
42
+ slider_handle: Sequence[str] = ()
43
+ # Inside the widget: the piece, measured to steer the slide and dragged directly when there is no track.
44
+ piece: Sequence[str] = ()
45
+
46
+
47
+ SELECTORS: Mapping[Vendor, VendorSelectors] = {
48
+ Vendor.RECAPTCHA: VendorSelectors(
49
+ hosts=("google.com/recaptcha", "recaptcha.net"),
50
+ challenge=('iframe[src*="recaptcha/api2/bframe"]',),
51
+ checkbox=('iframe[src*="recaptcha/api2/anchor"]:not([src*="size=invisible"])',),
52
+ response='[name="g-recaptcha-response"]',
53
+ checked=".recaptcha-checkbox-checked",
54
+ fresh=".rc-imageselect-instructions, #rc-imageselect",
55
+ submit=("#recaptcha-verify-button",),
56
+ banners=(
57
+ (".rc-imageselect-error-select-more", RecaptchaBanner.SELECT_MORE),
58
+ (".rc-imageselect-error-dynamic-more", RecaptchaBanner.DYNAMIC_MORE),
59
+ (".rc-imageselect-incorrect-response", RecaptchaBanner.REJECTED),
60
+ ),
61
+ ),
62
+ # Keyed on the `hcaptcha` substring, not the apex host: challenges are served off newassets.hcaptcha.com.
63
+ Vendor.HCAPTCHA: VendorSelectors(
64
+ hosts=("hcaptcha.com",),
65
+ challenge=('iframe[src*="hcaptcha"][src*="frame=challenge"]',),
66
+ checkbox=('iframe[src*="hcaptcha"][src*="frame=checkbox"]',),
67
+ response='[name="h-captcha-response"]',
68
+ checked='#checkbox[aria-checked="true"]',
69
+ fresh=".prompt-text",
70
+ images=(".task-image .image", ".task .image", ".challenge-example img", ".image-wrapper img"),
71
+ submit=(".button-submit",),
72
+ ),
73
+ Vendor.TURNSTILE: VendorSelectors(
74
+ hosts=("challenges.cloudflare.com",),
75
+ checkbox=('iframe[src*="challenges.cloudflare.com"]', ".cf-turnstile"),
76
+ response='[name="cf-turnstile-response"]',
77
+ ),
78
+ Vendor.GEETEST: VendorSelectors(
79
+ hosts=("geetest.com",),
80
+ widget=(".geetest_box", ".geetest_panel_box", ".geetest_popup_window", ".geetest_widget"),
81
+ accepted=(".geetest_result_tips.geetest_success, .geetest_captcha.geetest_success, "
82
+ ".geetest_captcha.geetest_lock_success"),
83
+ submit=(".geetest_submit",),
84
+ slider_handle=(".geetest_slider_button", ".geetest_btn", ".geetest_slider .geetest_arrow"),
85
+ piece=(".geetest_slice",),
86
+ ),
87
+ # Both in-page and iframe shapes stay (Tencent moved in-host on 2026-08-11; see TRIBAL_KNOWLEDGE.md), and the iframe
88
+ # id is prefix-anchored: `[id*=tcaptcha]` also matched MTCaptcha's iframe and hid that `.mtcap` matched nothing.
89
+ Vendor.TENCENT: VendorSelectors(
90
+ hosts=("captcha.gtimg.com", "captcha.qcloud.com"),
91
+ widget=("#tcaptcha_transform_dy", "#tCaptchaDyContent", ".tencent-captcha-dy__content", "iframe#tcaptcha_iframe_dy",
92
+ 'iframe[id^="tcaptcha"]', 'iframe[src*="captcha.gtimg.com"]', 'iframe[src*="captcha.qq.com"]'),
93
+ slider_handle=(".tencent-captcha-dy__slider-block", "#tcaptcha_drag_thumb", ".tc-slider-normal", "[id*=slideBlock]"),
94
+ piece=(".tencent-captcha-dy__fg-item",),
95
+ ),
96
+ Vendor.YIDUN: VendorSelectors(
97
+ hosts=("dun.163.com", "cstaticdun.126.net", "necaptcha.nosdn.127.net"),
98
+ widget=(".yidun_panel", ".yidun"),
99
+ slider_handle=(".yidun_slider", ".yidun_jigsaw"),
100
+ piece=(".yidun_jigsaw",),
101
+ ),
102
+ Vendor.YANDEX: VendorSelectors(
103
+ hosts=("smartcaptcha.yandexcloud.net",),
104
+ widget=('iframe[src*="smartcaptcha.yandexcloud.net/advanced"]', 'iframe[src*="smartcaptcha.yandexcloud.net"]',
105
+ ".CheckboxCaptcha"),
106
+ text_input=(".AdvancedCaptcha-Input input", "input.Textinput-Control", 'input[name="rep"]'),
107
+ ),
108
+ Vendor.LEMIN: VendorSelectors(
109
+ hosts=("leminnow.com",),
110
+ widget=("#lemin-cropped-captcha", ".lemin-captcha-popup"),
111
+ slider_handle=(".lemin-slider-handle", "#lemin-cropped-captcha .slider"),
112
+ piece=(".lemin-cropped-puzzle-piece", "#lemin-cropped-captcha canvas + canvas"),
113
+ ),
114
+ Vendor.PROSOPO: VendorSelectors(
115
+ hosts=("prosopo.io",),
116
+ widget=(".prosopo-modalInner", ".procaptcha-checkbox"),
117
+ ),
118
+ Vendor.MTCAPTCHA: VendorSelectors(
119
+ hosts=("mtcaptcha.com",),
120
+ widget=('iframe[src*="service.mtcaptcha.com"]', 'iframe[id^="mtcaptcha-iframe"]', ".mtcaptcha", ".mtcap"),
121
+ text_input=("input.mtcap-inputtext", ".mtcap input[type=text]"),
122
+ ),
123
+ Vendor.BOTDETECT: VendorSelectors(
124
+ hosts=(),
125
+ widget=(".BDC_CaptchaDiv",),
126
+ text_input=("input[id*=captchaCode]", "input#captchaCode", "input[id*=validateCaptcha]",
127
+ ".BDC_CaptchaDiv input[type=text]"),
128
+ ),
129
+ Vendor.UNKNOWN: VendorSelectors(hosts=()),
130
+ }
131
+ assert set(SELECTORS) == set(Vendor), "every vendor names its selectors"
132
+
133
+ VENDORS: Sequence[Tuple[Vendor, VendorSelectors]] = tuple(SELECTORS.items())
134
+
135
+
136
+ def _of_every_vendor(key: str) -> Sequence[str]:
137
+ assert key in {f.name for f in fields(VendorSelectors)}
138
+ return tuple(s for _, v in VENDORS for s in getattr(v, key))
139
+
140
+
141
+ class WidgetProbe(NamedTuple):
142
+ vendor: Vendor
143
+ role: FrameRole
144
+ selector: str
145
+
146
+
147
+ # Every host-page selector with what it names, in detection order: open challenges, then checkboxes, then inline widgets.
148
+ WIDGET_PROBES: Sequence[WidgetProbe] = tuple(
149
+ WidgetProbe(vendor, role, selector)
150
+ for role, key in ((FrameRole.CHALLENGE, "challenge"), (FrameRole.CHECKBOX, "checkbox"), (FrameRole.UNKNOWN, "widget"))
151
+ for vendor, s in VENDORS
152
+ for selector in getattr(s, key))
153
+
154
+ RESPONSE_SELECTORS: Sequence[str] = tuple(s.response for _, s in VENDORS if s.response)
155
+ ACCEPTED_SELECTORS: Sequence[str] = tuple(s.accepted for _, s in VENDORS if s.accepted)
156
+
157
+ _LOWER = "translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')"
158
+
159
+
160
+ def submit_by_text(text: str) -> str:
161
+ """`.//` keeps the xpath relative so a host-page widget cannot reach the form's own submit."""
162
+ return f"xpath=.//button[contains({_LOWER}, '{text}')] | .//div[@role=\"button\" and contains({_LOWER}, '{text}')]"
163
+
164
+
165
+ # A real Verify/Next/Submit/Skip first; the vendor-named controls are fallbacks.
166
+ SUBMIT_SELECTORS: Sequence[str] = (*map(submit_by_text, ("verify", "next", "submit", "skip")), *_of_every_vendor("submit"))
167
+
168
+ # Vendor-named first, generic last: the driver takes the first visible match, and a generic selector
169
+ # reached before the vendor's own is how a captcha's answer ends up in a login form's username box.
170
+ TEXT_INPUT_VENDOR_SELECTORS: Sequence[str] = _of_every_vendor("text_input")
171
+ TEXT_INPUT_SELECTORS: Sequence[str] = (
172
+ *TEXT_INPUT_VENDOR_SELECTORS,
173
+ 'input[name*="captcha" i]', 'input[id*="captcha" i]', 'input[aria-label*="captcha" i]',
174
+ 'input[placeholder*="code" i]', 'input[autocomplete="off"][type=text]',
175
+ "input[type=text]", "input:not([type])", "input[type=tel]", "textarea",
176
+ )
177
+ # `[draggable=true]` is absent: HTML5 DnD fires dragstart, not pointermove.
178
+ SLIDER_HANDLE_SELECTORS: Sequence[str] = (
179
+ *_of_every_vendor("slider_handle"),
180
+ '[role="slider"]', "[aria-valuenow]",
181
+ '[class*="slider"][class*="btn"]', '[class*="slider"][class*="button"]',
182
+ '[class*="slide"][class*="handle"]', '[class*="drag"][class*="thumb"]',
183
+ )
184
+ PIECE_SELECTORS: Sequence[str] = (*_of_every_vendor("piece"), '[class*="puzzle"][class*="piece"]', '[class*="jigsaw"]')
@@ -1,21 +1,3 @@
1
- """
2
- Hands-off local vLLM lifecycle.
3
-
4
- The client never has to think about the server. When the configured endpoint is
5
- LOCAL and nothing is answering, we start `vllm serve` exactly once — guarded by a
6
- file lock so concurrent first-callers don't double-spawn — wait for it to become
7
- healthy, and then proceed. When VLLM_BASE_URL points at a REMOTE host, we assume
8
- the user manages that server and do nothing (so passing your own URL is all it
9
- takes to opt out of local management entirely).
10
-
11
- Public API:
12
- ensure_server(base_url=None) -> called automatically before the first solve
13
- start(background=True) -> `captchakraken server start`
14
- run_foreground() -> `captchakraken server run` (exec vllm)
15
- stop() -> `captchakraken server stop`
16
- status(base_url=None) -> dict for `captchakraken server status`
17
- """
18
-
19
1
  import os
20
2
  import shutil
21
3
  import signal
@@ -29,14 +11,11 @@ import requests
29
11
 
30
12
  from . import config
31
13
 
32
- # One shared state dir for the pidfile, lockfile, server log, and the hosted-API
33
- # credentials file. Defined in `config` so the path has a single definition.
34
14
  STATE_DIR = config.state_dir()
35
15
  LOCK_FILE = STATE_DIR / "vllm.lock"
36
16
  PID_FILE = STATE_DIR / "vllm.pid"
37
17
  LOG_FILE = STATE_DIR / "vllm.log"
38
18
 
39
- # vLLM cold-starts slowly (weights + LoRA + KV cache warmup). Give it room.
40
19
  STARTUP_TIMEOUT_S = int(os.getenv("CAPTCHA_KRAKEN_STARTUP_TIMEOUT", "600"))
41
20
 
42
21
 
@@ -56,7 +35,6 @@ def is_local(base_url: str) -> bool:
56
35
 
57
36
 
58
37
  def is_healthy(base_url: str, timeout: float = 2.0) -> bool:
59
- """True once vLLM answers /health. Cheap enough for the hot-path fast check."""
60
38
  try:
61
39
  r = requests.get(_server_root(base_url) + "/health", timeout=timeout)
62
40
  return r.ok
@@ -65,10 +43,7 @@ def is_healthy(base_url: str, timeout: float = 2.0) -> bool:
65
43
 
66
44
 
67
45
  def _vllm_bin() -> "str | None":
68
- """Locate the `vllm` executable. Prefer the one next to the CURRENT Python
69
- interpreter (the venv the CLI is running in) — the JS driver invokes us via
70
- the venv python without the venv's bin on PATH, so `shutil.which` alone would
71
- miss it. Fall back to PATH."""
46
+ """Next to the current interpreter first: the JS driver invokes the venv python without its bin on PATH."""
72
47
  sibling = os.path.join(os.path.dirname(sys.executable), "vllm")
73
48
  if os.path.exists(sibling) and os.access(sibling, os.X_OK):
74
49
  return sibling
@@ -85,21 +60,17 @@ def _read_pid() -> "int | None":
85
60
  except (OSError, ValueError):
86
61
  return None
87
62
  try:
88
- os.kill(pid, 0) # signal 0 == liveness probe
63
+ os.kill(pid, 0)
89
64
  return pid
90
65
  except OSError:
91
66
  return None
92
67
 
93
68
 
94
69
  def build_serve_command() -> "list[str]":
95
- """The `vllm serve …` argv, assembled entirely from config (model-agnostic).
96
-
97
- --enable-tower-connector-lora is REQUIRED: without it vLLM silently drops the
98
- vision-tower half of the LoRA and grid accuracy collapses.
99
- """
100
70
  return [
101
71
  _vllm_bin() or "vllm", "serve", config.base_model(),
102
72
  "--reasoning-parser", "qwen3",
73
+ # REQUIRED: without it vLLM silently drops the vision-tower half of the LoRA and grid accuracy collapses.
103
74
  "--enable-lora", "--enable-tower-connector-lora",
104
75
  "--max-lora-rank", str(config.max_lora_rank()),
105
76
  "--max-model-len", str(config.max_model_len()),
@@ -113,9 +84,8 @@ def build_serve_command() -> "list[str]":
113
84
 
114
85
  def _serve_env() -> dict:
115
86
  env = dict(os.environ)
116
- # The server's bearer must match what the client sends. api_key() falls back
117
- # to "EMPTY"; only forward a real key (leaving vLLM open when none is set).
118
87
  key = config.api_key()
88
+ # Only forward a real key; api_key() falls back to "EMPTY", which would lock vLLM to a placeholder.
119
89
  if key and key != "EMPTY":
120
90
  env["VLLM_API_KEY"] = key
121
91
  return env
@@ -134,8 +104,6 @@ def _spawn() -> subprocess.Popen:
134
104
  logf = open(LOG_FILE, "ab", buffering=0)
135
105
  logf.write(f"\n=== captchakraken starting vLLM at {time.strftime('%Y-%m-%dT%H:%M:%S')} ===\n"
136
106
  .encode())
137
- # Detach into its own process group so it survives the caller and a later
138
- # `server stop` can signal the whole group.
139
107
  proc = subprocess.Popen(
140
108
  cmd,
141
109
  stdout=logf,
@@ -153,7 +121,6 @@ def _wait_healthy(base_url: str, timeout: float) -> bool:
153
121
  while time.time() < deadline:
154
122
  if is_healthy(base_url, timeout=2.0):
155
123
  return True
156
- # If the child died, stop waiting and surface the log.
157
124
  pid = _read_pid()
158
125
  if pid is None:
159
126
  return False
@@ -162,22 +129,10 @@ def _wait_healthy(base_url: str, timeout: float) -> bool:
162
129
 
163
130
 
164
131
  def ensure_server(base_url: "str | None" = None) -> None:
165
- """Guarantee an endpoint is reachable before the first request.
166
-
167
- Fast path: one /health GET when the server is already up. Otherwise, for a
168
- LOCAL endpoint with autostart enabled, start vLLM once (lock-guarded) and
169
- block until healthy. For a REMOTE endpoint we never spawn — the user owns it.
170
- """
171
132
  base_url = base_url or config.base_url()
172
133
 
173
- # REMOTE FIRST. There is nothing to ensure about a server we do not manage,
174
- # so asking it for /health before deciding that is a round trip spent to
175
- # reach a `return` — and up to `timeout=2.0` of it against a hosted gateway
176
- # that serves no /health at all. Paid once per ActionPlanner, which for a
177
- # caller using `solve_captcha_on_page` is once per solve.
134
+ # Remote first: /health against a hosted gateway that serves none costs up to 2s, once per ActionPlanner.
178
135
  if not is_local(base_url):
179
- # Let the actual request raise a clear connection error rather than
180
- # silently trying to boot vLLM locally.
181
136
  return
182
137
  if is_healthy(base_url):
183
138
  return
@@ -185,11 +140,9 @@ def ensure_server(base_url: "str | None" = None) -> None:
185
140
  return
186
141
 
187
142
  STATE_DIR.mkdir(parents=True, exist_ok=True)
188
- # File lock so two solves racing on first-use don't both spawn a server.
143
+ # Two solves racing on first use must not both spawn a server; re-check under the lock.
189
144
  lock = _FileLock(LOCK_FILE)
190
145
  with lock:
191
- # Re-check under the lock: another process may have started it while we
192
- # queued, or a previous spawn may still be warming up.
193
146
  if is_healthy(base_url):
194
147
  return
195
148
  if _read_pid() is None:
@@ -201,13 +154,12 @@ def ensure_server(base_url: "str | None" = None) -> None:
201
154
  )
202
155
 
203
156
 
204
- # ── CLI-facing lifecycle helpers ────────────────────────────────────────────
205
157
  def start(background: bool = True) -> dict:
206
158
  base_url = config.base_url()
207
159
  if is_healthy(base_url):
208
160
  return {"status": "already-running", "base_url": base_url, "pid": _read_pid()}
209
161
  if not background:
210
- run_foreground() # never returns
162
+ run_foreground()
211
163
  _spawn()
212
164
  ok = _wait_healthy(base_url, STARTUP_TIMEOUT_S)
213
165
  return {
@@ -219,7 +171,6 @@ def start(background: bool = True) -> dict:
219
171
 
220
172
 
221
173
  def run_foreground() -> None:
222
- """Exec `vllm serve` in the foreground (replaces this process)."""
223
174
  if not _vllm_available():
224
175
  raise RuntimeError(
225
176
  "vLLM is not installed. Run ./setup.sh or "
@@ -266,8 +217,6 @@ def status(base_url: "str | None" = None) -> dict:
266
217
 
267
218
 
268
219
  class _FileLock:
269
- """Minimal POSIX advisory lock (fcntl). vLLM is Linux-only, so this suffices;
270
- on platforms without fcntl it degrades to a best-effort no-op."""
271
220
 
272
221
  def __init__(self, path: Path):
273
222
  self.path = path