captchakraken 2.8.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.
- package/AGENTS.md +112 -0
- package/NOTICE +36 -0
- package/dist/cli-invocation.d.ts +9 -40
- package/dist/cli-invocation.js +16 -49
- package/dist/errors.d.ts +7 -27
- package/dist/errors.js +6 -25
- package/dist/humanize.d.ts +56 -240
- package/dist/humanize.js +159 -415
- package/dist/index.d.ts +5 -129
- package/dist/index.js +4 -98
- package/dist/kinds.d.ts +135 -0
- package/dist/kinds.js +70 -0
- package/dist/limits.d.ts +0 -48
- package/dist/limits.js +2 -48
- package/dist/model-name.d.ts +4 -19
- package/dist/model-name.js +32 -40
- package/dist/playwright-types.d.ts +25 -95
- package/dist/playwright-types.js +0 -23
- package/dist/puppeteer-adapter.d.ts +4 -43
- package/dist/puppeteer-adapter.js +22 -58
- package/dist/python-command.d.ts +0 -41
- package/dist/python-command.js +0 -37
- package/dist/selectors.d.ts +54 -0
- package/dist/selectors.js +117 -0
- package/dist/slide-geometry.d.ts +2 -29
- package/dist/slide-geometry.js +18 -44
- package/dist/solver.d.ts +112 -539
- package/dist/solver.js +1088 -2950
- package/dist/timing.d.ts +7 -40
- package/dist/timing.js +4 -36
- package/dist/token-usage.d.ts +1 -7
- package/dist/token-usage.js +10 -95
- package/dist/types.d.ts +77 -483
- package/dist/watcher.d.ts +5 -78
- package/dist/watcher.js +0 -56
- package/package.json +12 -4
- package/python/AGENTS.md +122 -0
- package/python/NOTICE +36 -0
- package/python/README.md +1 -2
- package/python/examples/README.md +10 -0
- package/python/examples/_harness.py +4 -73
- package/python/examples/demoHcaptcha.py +0 -13
- package/python/examples/demoRecaptcha.py +0 -10
- package/python/examples/watch_playwright.py +1 -26
- package/python/examples/with_local_gguf.py +28 -0
- package/python/examples/with_playwright.py +0 -19
- package/python/pyproject.toml +68 -19
- package/python/src/captchakraken/__init__.py +37 -45
- package/python/src/captchakraken/action_types.py +14 -36
- package/python/src/captchakraken/cli.py +262 -879
- package/python/src/captchakraken/config.py +60 -146
- package/python/src/captchakraken/errors.py +17 -95
- package/python/src/captchakraken/humanize.py +174 -597
- package/python/src/captchakraken/image_processor.py +16 -235
- package/python/src/captchakraken/keyframes.py +69 -434
- package/python/src/captchakraken/kinds.py +152 -0
- package/python/src/captchakraken/models.json +79 -21
- package/python/src/captchakraken/overlay.py +65 -498
- package/python/src/captchakraken/page_solver.py +1123 -2564
- package/python/src/captchakraken/planner.py +70 -428
- package/python/src/captchakraken/prompts.py +45 -261
- package/python/src/captchakraken/selectors.py +184 -0
- package/python/src/captchakraken/server_manager.py +7 -58
- package/python/src/captchakraken/solver.py +128 -522
- package/python/src/captchakraken/timing.py +20 -74
- package/python/src/captchakraken/tool_calls/board_painted.py +47 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +20 -30
- package/python/src/captchakraken/tool_calls/find_grid.py +186 -1339
- package/python/src/captchakraken/tool_calls/track_piece.py +81 -57
- package/python/src/captchakraken/updater.py +7 -76
- package/python/src/captchakraken/watcher.py +12 -101
- package/scripts/copy-python.mjs +10 -10
- package/scripts/setup-python.js +1 -21
- package/dist/trajectory.d.ts +0 -58
- package/dist/trajectory.js +0 -266
- package/python/src/captchakraken/tool_calls/move_indicator.py +0 -431
- 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
|
-
"""
|
|
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)
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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()
|
|
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
|