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.
Files changed (37) hide show
  1. package/README.md +54 -0
  2. package/dist/index.d.ts +56 -0
  3. package/dist/index.js +43 -0
  4. package/dist/playwright-types.d.ts +119 -0
  5. package/dist/playwright-types.js +25 -0
  6. package/dist/puppeteer-adapter.d.ts +70 -0
  7. package/dist/puppeteer-adapter.js +96 -0
  8. package/dist/solver.d.ts +264 -0
  9. package/dist/solver.js +1922 -0
  10. package/dist/token-usage.d.ts +15 -0
  11. package/dist/token-usage.js +102 -0
  12. package/dist/types.d.ts +248 -0
  13. package/dist/types.js +2 -0
  14. package/package.json +49 -0
  15. package/python/Dockerfile +41 -0
  16. package/python/README.md +71 -0
  17. package/python/examples/README.md +68 -0
  18. package/python/examples/_harness.py +158 -0
  19. package/python/examples/demoHcaptcha.py +20 -0
  20. package/python/examples/demoRecaptcha.py +17 -0
  21. package/python/pyproject.toml +79 -0
  22. package/python/src/captchakraken/__init__.py +61 -0
  23. package/python/src/captchakraken/action_types.py +56 -0
  24. package/python/src/captchakraken/cli.py +656 -0
  25. package/python/src/captchakraken/config.py +78 -0
  26. package/python/src/captchakraken/image_processor.py +244 -0
  27. package/python/src/captchakraken/overlay.py +520 -0
  28. package/python/src/captchakraken/planner.py +408 -0
  29. package/python/src/captchakraken/planner_types.py +74 -0
  30. package/python/src/captchakraken/server_manager.py +290 -0
  31. package/python/src/captchakraken/solver.py +434 -0
  32. package/python/src/captchakraken/timing.py +42 -0
  33. package/python/src/captchakraken/tool_calls/find_checkbox.py +72 -0
  34. package/python/src/captchakraken/tool_calls/find_grid.py +1762 -0
  35. package/python/src/captchakraken/tool_calls/move_indicator.py +431 -0
  36. package/scripts/copy-python.mjs +29 -0
  37. 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