captchakraken 2.1.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.
- package/README.md +54 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +43 -0
- package/dist/playwright-types.d.ts +119 -0
- package/dist/playwright-types.js +25 -0
- package/dist/puppeteer-adapter.d.ts +70 -0
- package/dist/puppeteer-adapter.js +96 -0
- package/dist/solver.d.ts +264 -0
- package/dist/solver.js +1922 -0
- package/dist/token-usage.d.ts +15 -0
- package/dist/token-usage.js +102 -0
- package/dist/types.d.ts +248 -0
- package/dist/types.js +2 -0
- package/package.json +49 -0
- package/python/Dockerfile +41 -0
- package/python/README.md +71 -0
- package/python/examples/README.md +68 -0
- package/python/examples/_harness.py +158 -0
- package/python/examples/demoHcaptcha.py +20 -0
- package/python/examples/demoRecaptcha.py +17 -0
- package/python/pyproject.toml +79 -0
- package/python/src/captchakraken/__init__.py +61 -0
- package/python/src/captchakraken/action_types.py +56 -0
- package/python/src/captchakraken/cli.py +656 -0
- package/python/src/captchakraken/config.py +78 -0
- package/python/src/captchakraken/image_processor.py +244 -0
- package/python/src/captchakraken/overlay.py +520 -0
- package/python/src/captchakraken/planner.py +408 -0
- package/python/src/captchakraken/planner_types.py +74 -0
- package/python/src/captchakraken/server_manager.py +290 -0
- package/python/src/captchakraken/solver.py +434 -0
- package/python/src/captchakraken/timing.py +42 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +72 -0
- package/python/src/captchakraken/tool_calls/find_grid.py +1762 -0
- package/python/src/captchakraken/tool_calls/move_indicator.py +431 -0
- package/scripts/copy-python.mjs +29 -0
- package/scripts/setup-python.js +104 -0
|
@@ -0,0 +1,290 @@
|
|
|
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
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import signal
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from urllib.parse import urlparse
|
|
27
|
+
|
|
28
|
+
import requests
|
|
29
|
+
|
|
30
|
+
from . import config
|
|
31
|
+
|
|
32
|
+
# One shared state dir for the pidfile, lockfile, and server log.
|
|
33
|
+
STATE_DIR = Path(os.getenv("CAPTCHA_KRAKEN_STATE_DIR", str(Path.home() / ".captchakraken")))
|
|
34
|
+
LOCK_FILE = STATE_DIR / "vllm.lock"
|
|
35
|
+
PID_FILE = STATE_DIR / "vllm.pid"
|
|
36
|
+
LOG_FILE = STATE_DIR / "vllm.log"
|
|
37
|
+
|
|
38
|
+
# vLLM cold-starts slowly (weights + LoRA + KV cache warmup). Give it room.
|
|
39
|
+
STARTUP_TIMEOUT_S = int(os.getenv("CAPTCHA_KRAKEN_STARTUP_TIMEOUT", "600"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _log(msg: str) -> None:
|
|
43
|
+
if os.getenv("CAPTCHA_DEBUG", "0") == "1":
|
|
44
|
+
print(f"[server] {msg}", file=sys.stderr)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _server_root(base_url: str) -> str:
|
|
48
|
+
p = urlparse(base_url)
|
|
49
|
+
return f"{p.scheme}://{p.netloc}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_local(base_url: str) -> bool:
|
|
53
|
+
host = (urlparse(base_url).hostname or "").lower()
|
|
54
|
+
return host in {"localhost", "127.0.0.1", "0.0.0.0", "::1", ""}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_healthy(base_url: str, timeout: float = 2.0) -> bool:
|
|
58
|
+
"""True once vLLM answers /health. Cheap enough for the hot-path fast check."""
|
|
59
|
+
try:
|
|
60
|
+
r = requests.get(_server_root(base_url) + "/health", timeout=timeout)
|
|
61
|
+
return r.ok
|
|
62
|
+
except requests.RequestException:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _vllm_bin() -> "str | None":
|
|
67
|
+
"""Locate the `vllm` executable. Prefer the one next to the CURRENT Python
|
|
68
|
+
interpreter (the venv the CLI is running in) — the JS driver invokes us via
|
|
69
|
+
the venv python without the venv's bin on PATH, so `shutil.which` alone would
|
|
70
|
+
miss it. Fall back to PATH."""
|
|
71
|
+
sibling = os.path.join(os.path.dirname(sys.executable), "vllm")
|
|
72
|
+
if os.path.exists(sibling) and os.access(sibling, os.X_OK):
|
|
73
|
+
return sibling
|
|
74
|
+
return shutil.which("vllm")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _vllm_available() -> bool:
|
|
78
|
+
return _vllm_bin() is not None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _read_pid() -> "int | None":
|
|
82
|
+
try:
|
|
83
|
+
pid = int(PID_FILE.read_text().strip())
|
|
84
|
+
except (OSError, ValueError):
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
os.kill(pid, 0) # signal 0 == liveness probe
|
|
88
|
+
return pid
|
|
89
|
+
except OSError:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def build_serve_command() -> "list[str]":
|
|
94
|
+
"""The `vllm serve …` argv, assembled entirely from config (model-agnostic).
|
|
95
|
+
|
|
96
|
+
--enable-tower-connector-lora is REQUIRED: without it vLLM silently drops the
|
|
97
|
+
vision-tower half of the LoRA and grid accuracy collapses.
|
|
98
|
+
"""
|
|
99
|
+
return [
|
|
100
|
+
_vllm_bin() or "vllm", "serve", config.base_model(),
|
|
101
|
+
"--reasoning-parser", "qwen3",
|
|
102
|
+
"--enable-lora", "--enable-tower-connector-lora",
|
|
103
|
+
"--max-lora-rank", str(config.max_lora_rank()),
|
|
104
|
+
"--max-model-len", str(config.max_model_len()),
|
|
105
|
+
"--gpu-memory-utilization", str(config.gpu_memory_utilization()),
|
|
106
|
+
"--trust-remote-code",
|
|
107
|
+
"--port", str(config.port()),
|
|
108
|
+
"--lora-modules", f"{config.lora_name()}={config.lora_adapter()}",
|
|
109
|
+
*config.extra_serve_args(),
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _serve_env() -> dict:
|
|
114
|
+
env = dict(os.environ)
|
|
115
|
+
# The server's bearer must match what the client sends. api_key() falls back
|
|
116
|
+
# to "EMPTY"; only forward a real key (leaving vLLM open when none is set).
|
|
117
|
+
key = config.api_key()
|
|
118
|
+
if key and key != "EMPTY":
|
|
119
|
+
env["VLLM_API_KEY"] = key
|
|
120
|
+
return env
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _spawn() -> subprocess.Popen:
|
|
124
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
if not _vllm_available():
|
|
126
|
+
raise RuntimeError(
|
|
127
|
+
"vLLM is not installed, so a local server can't be started. Run the "
|
|
128
|
+
"setup script (./setup.sh) or `pip install \"captchakraken[serve]\"`, "
|
|
129
|
+
"or point VLLM_BASE_URL at a server you already run."
|
|
130
|
+
)
|
|
131
|
+
cmd = build_serve_command()
|
|
132
|
+
_log("starting: " + " ".join(cmd))
|
|
133
|
+
logf = open(LOG_FILE, "ab", buffering=0)
|
|
134
|
+
logf.write(f"\n=== captchakraken starting vLLM at {time.strftime('%Y-%m-%dT%H:%M:%S')} ===\n"
|
|
135
|
+
.encode())
|
|
136
|
+
# Detach into its own process group so it survives the caller and a later
|
|
137
|
+
# `server stop` can signal the whole group.
|
|
138
|
+
proc = subprocess.Popen(
|
|
139
|
+
cmd,
|
|
140
|
+
stdout=logf,
|
|
141
|
+
stderr=logf,
|
|
142
|
+
stdin=subprocess.DEVNULL,
|
|
143
|
+
env=_serve_env(),
|
|
144
|
+
start_new_session=True,
|
|
145
|
+
)
|
|
146
|
+
PID_FILE.write_text(str(proc.pid))
|
|
147
|
+
return proc
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _wait_healthy(base_url: str, timeout: float) -> bool:
|
|
151
|
+
deadline = time.time() + timeout
|
|
152
|
+
while time.time() < deadline:
|
|
153
|
+
if is_healthy(base_url, timeout=2.0):
|
|
154
|
+
return True
|
|
155
|
+
# If the child died, stop waiting and surface the log.
|
|
156
|
+
pid = _read_pid()
|
|
157
|
+
if pid is None:
|
|
158
|
+
return False
|
|
159
|
+
time.sleep(2.0)
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def ensure_server(base_url: "str | None" = None) -> None:
|
|
164
|
+
"""Guarantee an endpoint is reachable before the first request.
|
|
165
|
+
|
|
166
|
+
Fast path: one /health GET when the server is already up. Otherwise, for a
|
|
167
|
+
LOCAL endpoint with autostart enabled, start vLLM once (lock-guarded) and
|
|
168
|
+
block until healthy. For a REMOTE endpoint we never spawn — the user owns it.
|
|
169
|
+
"""
|
|
170
|
+
base_url = base_url or config.base_url()
|
|
171
|
+
|
|
172
|
+
if is_healthy(base_url):
|
|
173
|
+
return
|
|
174
|
+
if not is_local(base_url):
|
|
175
|
+
# Remote server we don't manage: let the actual request raise a clear
|
|
176
|
+
# connection error rather than silently trying to boot vLLM locally.
|
|
177
|
+
return
|
|
178
|
+
if not config.autostart_enabled():
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
# File lock so two solves racing on first-use don't both spawn a server.
|
|
183
|
+
lock = _FileLock(LOCK_FILE)
|
|
184
|
+
with lock:
|
|
185
|
+
# Re-check under the lock: another process may have started it while we
|
|
186
|
+
# queued, or a previous spawn may still be warming up.
|
|
187
|
+
if is_healthy(base_url):
|
|
188
|
+
return
|
|
189
|
+
if _read_pid() is None:
|
|
190
|
+
_spawn()
|
|
191
|
+
if not _wait_healthy(base_url, STARTUP_TIMEOUT_S):
|
|
192
|
+
raise RuntimeError(
|
|
193
|
+
f"vLLM did not become healthy at {base_url} within "
|
|
194
|
+
f"{STARTUP_TIMEOUT_S}s. See {LOG_FILE} for details."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ── CLI-facing lifecycle helpers ────────────────────────────────────────────
|
|
199
|
+
def start(background: bool = True) -> dict:
|
|
200
|
+
base_url = config.base_url()
|
|
201
|
+
if is_healthy(base_url):
|
|
202
|
+
return {"status": "already-running", "base_url": base_url, "pid": _read_pid()}
|
|
203
|
+
if not background:
|
|
204
|
+
run_foreground() # never returns
|
|
205
|
+
_spawn()
|
|
206
|
+
ok = _wait_healthy(base_url, STARTUP_TIMEOUT_S)
|
|
207
|
+
return {
|
|
208
|
+
"status": "running" if ok else "timeout",
|
|
209
|
+
"base_url": base_url,
|
|
210
|
+
"pid": _read_pid(),
|
|
211
|
+
"log": str(LOG_FILE),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def run_foreground() -> None:
|
|
216
|
+
"""Exec `vllm serve` in the foreground (replaces this process)."""
|
|
217
|
+
if not _vllm_available():
|
|
218
|
+
raise RuntimeError(
|
|
219
|
+
"vLLM is not installed. Run ./setup.sh or "
|
|
220
|
+
"`pip install \"captchakraken[serve]\"`."
|
|
221
|
+
)
|
|
222
|
+
cmd = build_serve_command()
|
|
223
|
+
os.execvpe(cmd[0], cmd, _serve_env())
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def stop() -> dict:
|
|
227
|
+
pid = _read_pid()
|
|
228
|
+
if pid is None:
|
|
229
|
+
return {"status": "not-running"}
|
|
230
|
+
try:
|
|
231
|
+
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
|
232
|
+
except OSError:
|
|
233
|
+
try:
|
|
234
|
+
os.kill(pid, signal.SIGTERM)
|
|
235
|
+
except OSError:
|
|
236
|
+
pass
|
|
237
|
+
for _ in range(20):
|
|
238
|
+
if _read_pid() is None:
|
|
239
|
+
break
|
|
240
|
+
time.sleep(0.5)
|
|
241
|
+
try:
|
|
242
|
+
PID_FILE.unlink()
|
|
243
|
+
except OSError:
|
|
244
|
+
pass
|
|
245
|
+
return {"status": "stopped", "pid": pid}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def status(base_url: "str | None" = None) -> dict:
|
|
249
|
+
base_url = base_url or config.base_url()
|
|
250
|
+
return {
|
|
251
|
+
"base_url": base_url,
|
|
252
|
+
"healthy": is_healthy(base_url),
|
|
253
|
+
"local": is_local(base_url),
|
|
254
|
+
"pid": _read_pid(),
|
|
255
|
+
"autostart": config.autostart_enabled(),
|
|
256
|
+
"base_model": config.base_model(),
|
|
257
|
+
"lora_adapter": config.lora_adapter(),
|
|
258
|
+
"lora_name": config.lora_name(),
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class _FileLock:
|
|
263
|
+
"""Minimal POSIX advisory lock (fcntl). vLLM is Linux-only, so this suffices;
|
|
264
|
+
on platforms without fcntl it degrades to a best-effort no-op."""
|
|
265
|
+
|
|
266
|
+
def __init__(self, path: Path):
|
|
267
|
+
self.path = path
|
|
268
|
+
self._fh = None
|
|
269
|
+
|
|
270
|
+
def __enter__(self):
|
|
271
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
272
|
+
self._fh = open(self.path, "w")
|
|
273
|
+
try:
|
|
274
|
+
import fcntl
|
|
275
|
+
|
|
276
|
+
fcntl.flock(self._fh, fcntl.LOCK_EX)
|
|
277
|
+
except (ImportError, OSError):
|
|
278
|
+
pass
|
|
279
|
+
return self
|
|
280
|
+
|
|
281
|
+
def __exit__(self, *exc):
|
|
282
|
+
if self._fh is not None:
|
|
283
|
+
try:
|
|
284
|
+
import fcntl
|
|
285
|
+
|
|
286
|
+
fcntl.flock(self._fh, fcntl.LOCK_UN)
|
|
287
|
+
except (ImportError, OSError):
|
|
288
|
+
pass
|
|
289
|
+
self._fh.close()
|
|
290
|
+
self._fh = None
|