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
|
@@ -1,28 +1,3 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Central, model-agnostic configuration for CaptchaKraken.
|
|
3
|
-
|
|
4
|
-
Every model-specific value lives here and is overridable via environment
|
|
5
|
-
variables, so swapping the underlying model never requires touching the solver,
|
|
6
|
-
planner, or CLI. The defaults point at the published unified captcha LoRA, but
|
|
7
|
-
nothing in the codebase hard-codes a model name outside this module.
|
|
8
|
-
|
|
9
|
-
The two variables MOST users ever set:
|
|
10
|
-
VLLM_BASE_URL where inference requests go (local or a remote server)
|
|
11
|
-
CAPTCHA_KRAKEN_API_KEY bearer token for that server
|
|
12
|
-
|
|
13
|
-
Advanced / self-hosting overrides (only needed to change WHICH model is served):
|
|
14
|
-
CAPTCHA_BASE_MODEL base weights vLLM loads (HF id or local path)
|
|
15
|
-
CAPTCHA_LORA_ADAPTER LoRA applied on top (HF id or local path)
|
|
16
|
-
CAPTCHA_LORA_REVISION adapter git revision to serve
|
|
17
|
-
CAPTCHA_LORA_NAME served adapter name the client asks for as `model`
|
|
18
|
-
|
|
19
|
-
Defaults for the model-identity values come from `pinned_model.json` (see
|
|
20
|
-
`pinned()`), which records the model + prompt pair this release was validated
|
|
21
|
-
against.
|
|
22
|
-
VLLM_PORT, VLLM_GPU_MEMORY_UTILIZATION, VLLM_MAX_MODEL_LEN, VLLM_MAX_LORA_RANK
|
|
23
|
-
CAPTCHA_KRAKEN_AUTOSTART=0 disable auto-starting a local server on first use
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
1
|
import json
|
|
27
2
|
import os
|
|
28
3
|
from functools import lru_cache
|
|
@@ -34,39 +9,12 @@ _PINNED_MODEL_PATH = Path(__file__).with_name("pinned_model.json")
|
|
|
34
9
|
|
|
35
10
|
@lru_cache(maxsize=1)
|
|
36
11
|
def pinned() -> Dict[str, Any]:
|
|
37
|
-
"""The pinned model manifest shipped with this release.
|
|
38
|
-
|
|
39
|
-
Defines the (base model, adapter, serving prompts) triple this version was
|
|
40
|
-
validated against. Env vars still override every value below — the manifest
|
|
41
|
-
supplies the DEFAULT and gives CI something to assert against, so an adapter
|
|
42
|
-
or prompt change is a reviewable diff instead of a silent drift.
|
|
43
|
-
"""
|
|
44
12
|
with _PINNED_MODEL_PATH.open(encoding="utf-8") as f:
|
|
45
13
|
return json.load(f)
|
|
46
14
|
|
|
47
15
|
|
|
48
|
-
# ── Inference endpoint ──────────────────────────────────────────────────────
|
|
49
16
|
def base_url() -> str:
|
|
50
|
-
"""
|
|
51
|
-
|
|
52
|
-
Precedence mirrors `api_key()` exactly — env, then the credentials file,
|
|
53
|
-
then the local default — because the two values are set together and a
|
|
54
|
-
reader who has learned one ordering should not have to learn a second.
|
|
55
|
-
|
|
56
|
-
1. VLLM_BASE_URL. An explicit override always wins; self-hosters and the
|
|
57
|
-
dev environment depend on this and must not be silently redirected.
|
|
58
|
-
2. The credentials file. Written by the MCP signup flow alongside the key,
|
|
59
|
-
so onboarding configures the endpoint in the same step and a hosted
|
|
60
|
-
user needs no env plumbing at all.
|
|
61
|
-
3. localhost. Unchanged, and still correct: someone with no credentials
|
|
62
|
-
file is by definition not a hosted user, so the self-hosted default is
|
|
63
|
-
the right guess for them.
|
|
64
|
-
|
|
65
|
-
The bug this closes: a camoufox user who signed up through the MCP had a
|
|
66
|
-
valid key and no endpoint, so every solve dialled a local port with nothing
|
|
67
|
-
behind it and failed with connection-refused — a message that says nothing
|
|
68
|
-
about the fact that their requests were meant to go to api.captchakraken.com.
|
|
69
|
-
"""
|
|
17
|
+
"""Env, then the credentials file, then localhost: a camoufox user who signed up through the MCP had a valid key and no endpoint, so every solve dialled a local port."""
|
|
70
18
|
return (
|
|
71
19
|
os.getenv("VLLM_BASE_URL")
|
|
72
20
|
or _base_url_from_credentials_file()
|
|
@@ -74,8 +22,28 @@ def base_url() -> str:
|
|
|
74
22
|
)
|
|
75
23
|
|
|
76
24
|
|
|
25
|
+
# An exact host list, not "is it remote": a self-hoster's vLLM across the network is remote too, and asking
|
|
26
|
+
# it for the hosted-only model 404s every request.
|
|
27
|
+
_HOSTED_HOSTS = ("api.captchakraken.com",)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def hosted_hosts() -> tuple:
|
|
31
|
+
extra = os.getenv("CAPTCHA_HOSTED_HOSTS", "")
|
|
32
|
+
return _HOSTED_HOSTS + tuple(
|
|
33
|
+
h.strip().lower() for h in extra.split(",") if h.strip())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_hosted_endpoint(url: Optional[str] = None) -> bool:
|
|
37
|
+
from urllib.parse import urlparse
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
host = (urlparse(url or base_url()).hostname or "").lower()
|
|
41
|
+
except Exception:
|
|
42
|
+
return False
|
|
43
|
+
return host in hosted_hosts()
|
|
44
|
+
|
|
45
|
+
|
|
77
46
|
def state_dir() -> Path:
|
|
78
|
-
"""Shared state dir for the pidfile, lockfile, server log, and credentials."""
|
|
79
47
|
return Path(os.getenv("CAPTCHA_KRAKEN_STATE_DIR", str(Path.home() / ".captchakraken")))
|
|
80
48
|
|
|
81
49
|
|
|
@@ -83,33 +51,12 @@ def credentials_path() -> Path:
|
|
|
83
51
|
return state_dir() / "credentials"
|
|
84
52
|
|
|
85
53
|
|
|
86
|
-
# Names the credentials file may use for each value. Two spellings each, because
|
|
87
|
-
# the file doubles as something a user can `source` — the VLLM_* names are the
|
|
88
|
-
# env vars this module already honours, and the CAPTCHA_KRAKEN_* names are what
|
|
89
|
-
# the product calls them. Accepting both costs nothing and spares anyone the
|
|
90
|
-
# discovery that they picked the wrong one.
|
|
91
54
|
_KEY_NAMES = ("CAPTCHA_KRAKEN_API_KEY", "VLLM_API_KEY")
|
|
92
55
|
_BASE_URL_NAMES = ("CAPTCHA_KRAKEN_BASE_URL", "VLLM_BASE_URL")
|
|
93
56
|
|
|
94
57
|
|
|
95
58
|
def _read_credentials_file() -> Dict[str, str]:
|
|
96
|
-
"""
|
|
97
|
-
|
|
98
|
-
Hosted-API onboarding writes the key to a 0600 file rather than returning it
|
|
99
|
-
through the agent transcript, so it never lands in an LLM context window.
|
|
100
|
-
Reading it here is what lets a solve authenticate with NO env vars set.
|
|
101
|
-
|
|
102
|
-
Returns a mapping of recognised name -> value. The special key ``""`` holds a
|
|
103
|
-
BARE TOKEN — a file containing nothing but `ck_live_…`, which is what the
|
|
104
|
-
original single-value format was and what a user hand-writing this file is
|
|
105
|
-
most likely to produce. Dropping that would silently break every existing
|
|
106
|
-
credentials file, so it is still accepted and still means "the API key".
|
|
107
|
-
|
|
108
|
-
Deliberately tolerant of quoting and comments: the file is written by a
|
|
109
|
-
*separate* tool, and a format mismatch here surfaces as a baffling 401
|
|
110
|
-
rather than an obvious parse error. Returns {} whenever the file is missing
|
|
111
|
-
or unreadable — the self-hosted case, which must stay silent.
|
|
112
|
-
"""
|
|
59
|
+
"""Two spellings per key because the file doubles as something a user can `source`; the bare-token form stays because dropping it would silently break every existing file."""
|
|
113
60
|
try:
|
|
114
61
|
text = credentials_path().read_text(encoding="utf-8")
|
|
115
62
|
except OSError:
|
|
@@ -123,20 +70,16 @@ def _read_credentials_file() -> Dict[str, str]:
|
|
|
123
70
|
if "=" in line:
|
|
124
71
|
name, _, value = line.partition("=")
|
|
125
72
|
name = name.strip()
|
|
126
|
-
# An env-file with unrelated keys must not yield a bogus token.
|
|
127
73
|
if name not in _KEY_NAMES and name not in _BASE_URL_NAMES:
|
|
128
74
|
continue
|
|
129
75
|
found.setdefault(name, value.strip().strip("'\""))
|
|
130
76
|
elif "" not in found:
|
|
131
|
-
# A bare token. Only the first such line counts; later prose in a
|
|
132
|
-
# hand-edited file must not overwrite the credential.
|
|
133
77
|
found[""] = line.strip("'\"")
|
|
134
78
|
|
|
135
79
|
return found
|
|
136
80
|
|
|
137
81
|
|
|
138
82
|
def _first_present(values: Dict[str, str], names) -> str:
|
|
139
|
-
"""First non-empty value among `names`, in the order given."""
|
|
140
83
|
for name in names:
|
|
141
84
|
value = values.get(name, "").strip()
|
|
142
85
|
if value:
|
|
@@ -145,28 +88,18 @@ def _first_present(values: Dict[str, str], names) -> str:
|
|
|
145
88
|
|
|
146
89
|
|
|
147
90
|
def _key_from_credentials_file() -> str:
|
|
148
|
-
"""The API key from the credentials file, or "" if there isn't one."""
|
|
149
91
|
values = _read_credentials_file()
|
|
150
92
|
return _first_present(values, _KEY_NAMES) or values.get("", "").strip()
|
|
151
93
|
|
|
152
94
|
|
|
153
|
-
|
|
154
|
-
|
|
95
|
+
# A bare token yields no endpoint on purpose: it would silently redirect a self-hoster who hand-wrote a local key here.
|
|
96
|
+
|
|
155
97
|
|
|
156
|
-
|
|
157
|
-
and inventing the hosted one for it would silently redirect a self-hoster
|
|
158
|
-
who wrote a token into that file by hand.
|
|
159
|
-
"""
|
|
98
|
+
def _base_url_from_credentials_file() -> str:
|
|
160
99
|
return _first_present(_read_credentials_file(), _BASE_URL_NAMES)
|
|
161
100
|
|
|
162
101
|
|
|
163
102
|
def api_key() -> str:
|
|
164
|
-
"""Bearer token, in precedence order.
|
|
165
|
-
|
|
166
|
-
Env first so an explicit override always wins, then the credentials file so
|
|
167
|
-
the MCP/hosted path needs no env plumbing at all, then "EMPTY" (which leaves
|
|
168
|
-
a self-hosted local vLLM open, as before).
|
|
169
|
-
"""
|
|
170
103
|
return (
|
|
171
104
|
os.getenv("CAPTCHA_KRAKEN_API_KEY")
|
|
172
105
|
or os.getenv("VLLM_API_KEY")
|
|
@@ -175,47 +108,10 @@ def api_key() -> str:
|
|
|
175
108
|
)
|
|
176
109
|
|
|
177
110
|
|
|
178
|
-
# ── Model identity (all overridable — the CLI itself is model-agnostic) ──────
|
|
179
|
-
#
|
|
180
|
-
# Precedence, for every value below:
|
|
181
|
-
#
|
|
182
|
-
# 1. The environment. An explicit override always wins — this is how you PIN
|
|
183
|
-
# to a specific model, and pinning is opt-in by design.
|
|
184
|
-
# 2. models.json's `latest` entry. The default moves forward with the
|
|
185
|
-
# registry, so "the default download" and "the default prompt" advance
|
|
186
|
-
# together and cannot get out of step: prompts.resolve() reads the SAME
|
|
187
|
-
# entry to decide which prompt generation to send.
|
|
188
|
-
# 3. pinned_model.json. The record of what this release was validated
|
|
189
|
-
# against, kept as the fallback so an older checkout or a hand-edited
|
|
190
|
-
# manifest still resolves.
|
|
191
|
-
#
|
|
192
|
-
# Order 2-before-3 is the behaviour change: `latest` leads. Today both name
|
|
193
|
-
# CaptchaKraken_v1.1, so nothing moves until a newer model is registered — at
|
|
194
|
-
# which point that IS the intent.
|
|
195
111
|
def _registry_default(field: str) -> Optional[str]:
|
|
196
|
-
"""
|
|
197
|
-
|
|
198
|
-
THE PIN DECIDES THE ENTRY, not just the adapter. This read `latest`
|
|
199
|
-
unconditionally, so `CAPTCHA_LORA_ADAPTER` moved the adapter and nothing
|
|
200
|
-
else: the base weights to load it onto, the served `lora_name` to put in
|
|
201
|
-
the request, and the revision all still came from whichever model happened
|
|
202
|
-
to be `latest`.
|
|
203
|
-
|
|
204
|
-
That was survivable while every registered model was a 9B on one base
|
|
205
|
-
answering to one served name. It stopped being survivable the day the
|
|
206
|
-
registry gained `CaptchaKraken/Abyss-27B` — a Qwen3.8-**27B** — because
|
|
207
|
-
pinning it downloaded a 9B base, tried to load a 27B adapter onto it, and
|
|
208
|
-
sent `captcha-v12` as the model name. Nothing in the client errors on that;
|
|
209
|
-
it errors deep inside vLLM as a shape mismatch, or not at all if the
|
|
210
|
-
endpoint happens to serve something by that name. Same shape for every
|
|
211
|
-
expert arm, whose whole point is a `lora_name` of its own.
|
|
212
|
-
|
|
213
|
-
An UNREGISTERED pin still falls back to `latest`. A self-hoster's own
|
|
214
|
-
adapter is not in our registry and never will be; changing what they
|
|
215
|
-
resolve to would be a break with no benefit.
|
|
216
|
-
"""
|
|
112
|
+
"""The pin decides the registry entry, not just the adapter: reading base/lora_name off `latest` once downloaded a 9B base for a pinned 27B adapter and failed deep in vLLM as a shape mismatch. An unregistered pin still falls back to `latest` for self-hosters."""
|
|
217
113
|
try:
|
|
218
|
-
from . import prompts
|
|
114
|
+
from . import prompts
|
|
219
115
|
|
|
220
116
|
models = prompts.registered_models()
|
|
221
117
|
entry = None
|
|
@@ -226,7 +122,7 @@ def _registry_default(field: str) -> Optional[str]:
|
|
|
226
122
|
entry = models.get(prompts.latest_model() or "")
|
|
227
123
|
value = (entry or {}).get(field)
|
|
228
124
|
return value if isinstance(value, str) else None
|
|
229
|
-
except Exception:
|
|
125
|
+
except Exception:
|
|
230
126
|
return None
|
|
231
127
|
|
|
232
128
|
|
|
@@ -236,7 +132,6 @@ def base_model() -> str:
|
|
|
236
132
|
|
|
237
133
|
|
|
238
134
|
def lora_adapter() -> str:
|
|
239
|
-
"""HF repo id or local path of the captcha adapter served on top of the base."""
|
|
240
135
|
from . import prompts
|
|
241
136
|
|
|
242
137
|
return (os.getenv("CAPTCHA_LORA_ADAPTER") or prompts.latest_model()
|
|
@@ -244,19 +139,44 @@ def lora_adapter() -> str:
|
|
|
244
139
|
|
|
245
140
|
|
|
246
141
|
def lora_revision() -> str:
|
|
247
|
-
"""Git revision of the adapter repo to serve — pinning this is what makes
|
|
248
|
-
'the adapter this release was tested against' a reproducible statement."""
|
|
249
142
|
return os.getenv("CAPTCHA_LORA_REVISION") or _registry_default("lora_revision") \
|
|
250
143
|
or pinned()["lora_revision"]
|
|
251
144
|
|
|
252
145
|
|
|
253
146
|
def lora_name() -> str:
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
147
|
+
# Pin, then the hosted default, then the registry: `latest` leads pinned_model.json so download and prompt advance together.
|
|
148
|
+
pin = os.getenv("CAPTCHA_LORA_NAME")
|
|
149
|
+
if pin:
|
|
150
|
+
return pin
|
|
151
|
+
if is_hosted_endpoint():
|
|
152
|
+
hosted = _hosted_default_name()
|
|
153
|
+
if hosted:
|
|
154
|
+
return hosted
|
|
155
|
+
return _registry_default("lora_name") or pinned()["lora_name"]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _hosted_default_name() -> Optional[str]:
|
|
159
|
+
"""Send the routing alias, not an arm: `abyss-general` is a lone expert with no `experts` of its own, and naming it would pin every family to the generalist and silently lose the routing."""
|
|
160
|
+
try:
|
|
161
|
+
from . import prompts
|
|
162
|
+
|
|
163
|
+
repo = prompts.hosted_default_model()
|
|
164
|
+
if not repo:
|
|
165
|
+
return None
|
|
166
|
+
aliases = [a for a, target in prompts.served_aliases().items()
|
|
167
|
+
if target == repo and not a.startswith("_")]
|
|
168
|
+
for alias in aliases:
|
|
169
|
+
if prompts.experts(alias):
|
|
170
|
+
return alias
|
|
171
|
+
if aliases:
|
|
172
|
+
return aliases[0]
|
|
173
|
+
entry = prompts.registered_models().get(repo) or {}
|
|
174
|
+
value = entry.get("lora_name")
|
|
175
|
+
return value if isinstance(value, str) else None
|
|
176
|
+
except Exception:
|
|
177
|
+
return None
|
|
257
178
|
|
|
258
179
|
|
|
259
|
-
# ── Local vLLM server knobs ─────────────────────────────────────────────────
|
|
260
180
|
def port() -> int:
|
|
261
181
|
return int(os.getenv("VLLM_PORT", "8000"))
|
|
262
182
|
|
|
@@ -274,15 +194,9 @@ def max_lora_rank() -> int:
|
|
|
274
194
|
|
|
275
195
|
|
|
276
196
|
def autostart_enabled() -> bool:
|
|
277
|
-
"""Whether to auto-launch a local server on first use (default on)."""
|
|
278
197
|
return os.getenv("CAPTCHA_KRAKEN_AUTOSTART", "1") != "0"
|
|
279
198
|
|
|
280
199
|
|
|
281
200
|
def extra_serve_args() -> list:
|
|
282
|
-
"""Extra flags appended verbatim to `vllm serve` (space-separated).
|
|
283
|
-
|
|
284
|
-
Escape hatch for serving tweaks the config doesn't model directly — e.g.
|
|
285
|
-
`--enforce-eager` or a smaller `--max-num-seqs` to squeeze onto a tight GPU.
|
|
286
|
-
"""
|
|
287
201
|
raw = os.getenv("VLLM_EXTRA_ARGS", "").strip()
|
|
288
202
|
return raw.split() if raw else []
|
|
@@ -1,47 +1,19 @@
|
|
|
1
1
|
"""What the hosted API's refusals mean, in words a user can act on.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
`vLLM 402 Payment Required at https://api.captchakraken.com/v1/chat/completions`.
|
|
7
|
-
A camoufox user who has never heard of vLLM, run vLLM, or intended to run vLLM
|
|
8
|
-
would hit that on the day their credits ran out.
|
|
9
|
-
|
|
10
|
-
Two rules, both inherited from the gateway's own `src/errors.ts`:
|
|
11
|
-
|
|
12
|
-
1. BRANCH ON `error.code`, NEVER ON PROSE. The messages get reworded; the codes
|
|
13
|
-
are the contract. A client that pattern-matches "Out of credits" breaks the
|
|
14
|
-
day someone improves the wording.
|
|
15
|
-
|
|
16
|
-
2. AN UNRECOGNISED CODE MUST STILL PRODUCE A USEFUL MESSAGE. There are ten
|
|
17
|
-
codes today and there will be more. Enumerating them exhaustively here means
|
|
18
|
-
the eleventh is reported worse than the ten — so the fallback carries the
|
|
19
|
-
server's own `message` and `resolution_url` through, and only the phrasing
|
|
20
|
-
is lost.
|
|
21
|
-
|
|
22
|
-
The self-hosted path is deliberately untouched: a local vLLM does not send this
|
|
23
|
-
envelope, so `from_response` returns a message shaped like the old one and the
|
|
24
|
-
401/403 hint about the bearer token survives. Nobody self-hosting sees a word
|
|
25
|
-
about credits.
|
|
3
|
+
Before this, a camoufox user out of credits read `vLLM 402 Payment Required at https://api.captchakraken.com/...`.
|
|
4
|
+
Branch on `code`, never on prose; an unknown code carries the server's own message through, so the eleventh
|
|
5
|
+
code is never reported worse than the ten. The self-hosted path (no envelope) is untouched.
|
|
26
6
|
"""
|
|
27
7
|
|
|
28
8
|
from typing import Any, Dict, Optional
|
|
29
9
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
# this, and when it does we prefer it.
|
|
10
|
+
from .kinds import ErrorCode
|
|
11
|
+
|
|
33
12
|
_DASHBOARD = "https://captchakraken.com/dashboard"
|
|
34
13
|
_SUPPORT = "https://captchakraken.com/support"
|
|
35
14
|
|
|
36
15
|
|
|
37
16
|
class CaptchaKrakenAPIError(RuntimeError):
|
|
38
|
-
"""A refusal from the hosted API, already translated.
|
|
39
|
-
|
|
40
|
-
Carries the machine-readable parts alongside the message so a caller that
|
|
41
|
-
wants to react programmatically (retry on `rate_limited`, stop on
|
|
42
|
-
`insufficient_credits`) can do so without re-parsing anything. `str(e)` is
|
|
43
|
-
the human sentence and is what reaches a console.
|
|
44
|
-
"""
|
|
45
17
|
|
|
46
18
|
def __init__(
|
|
47
19
|
self,
|
|
@@ -59,13 +31,6 @@ class CaptchaKrakenAPIError(RuntimeError):
|
|
|
59
31
|
self.retry_after_seconds = retry_after_seconds
|
|
60
32
|
|
|
61
33
|
def to_payload(self) -> Dict[str, Any]:
|
|
62
|
-
"""The shape the CLI puts on stderr for the JS driver to pick up.
|
|
63
|
-
|
|
64
|
-
The JS half shells out to this CLI and can only see stdout/stderr, so
|
|
65
|
-
the structured fields would otherwise be flattened to a string at the
|
|
66
|
-
process boundary and have to be re-parsed out of prose — exactly the
|
|
67
|
-
thing rule 1 forbids.
|
|
68
|
-
"""
|
|
69
34
|
return {
|
|
70
35
|
"error": str(self),
|
|
71
36
|
"ck_error": {
|
|
@@ -78,15 +43,10 @@ class CaptchaKrakenAPIError(RuntimeError):
|
|
|
78
43
|
|
|
79
44
|
|
|
80
45
|
def _retry_after(headers: Any) -> Optional[float]:
|
|
81
|
-
"""
|
|
82
|
-
|
|
83
|
-
HTTP also permits an HTTP-date here. The gateway only ever sends deltas, and
|
|
84
|
-
guessing wrong about a date would produce a confidently incorrect "wait 3
|
|
85
|
-
seconds", so anything unparseable is dropped rather than approximated.
|
|
86
|
-
"""
|
|
46
|
+
"""Numeric form only: guessing wrong about an HTTP-date would produce a confident "wait 3 seconds"."""
|
|
87
47
|
try:
|
|
88
48
|
raw = headers.get("Retry-After")
|
|
89
|
-
except Exception:
|
|
49
|
+
except Exception:
|
|
90
50
|
return None
|
|
91
51
|
if raw is None:
|
|
92
52
|
return None
|
|
@@ -97,19 +57,13 @@ def _retry_after(headers: Any) -> Optional[float]:
|
|
|
97
57
|
|
|
98
58
|
|
|
99
59
|
def _sentence(code: str, message: str, url: Optional[str], retry: Optional[float]) -> str:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
Every branch names CaptchaKraken. That is the entire point of this module:
|
|
103
|
-
the person reading it needs to know which product is refusing them before
|
|
104
|
-
anything else in the sentence can help.
|
|
105
|
-
"""
|
|
106
|
-
if code == "insufficient_credits":
|
|
60
|
+
if code == ErrorCode.INSUFFICIENT_CREDITS:
|
|
107
61
|
return (
|
|
108
62
|
"CaptchaKraken: your account is out of credits, so this solve was refused. "
|
|
109
63
|
f"Top up at {url or _DASHBOARD} and retry."
|
|
110
64
|
)
|
|
111
65
|
|
|
112
|
-
if code ==
|
|
66
|
+
if code == ErrorCode.SOLVE_ABANDONED:
|
|
113
67
|
return (
|
|
114
68
|
"CaptchaKraken: this captcha attempt was served too many times without "
|
|
115
69
|
"settling and has been abandoned. That usually means the IP reputation or "
|
|
@@ -118,24 +72,24 @@ def _sentence(code: str, message: str, url: Optional[str], retry: Optional[float
|
|
|
118
72
|
"one per solve() call, so a new solve is enough)."
|
|
119
73
|
)
|
|
120
74
|
|
|
121
|
-
if code ==
|
|
75
|
+
if code == ErrorCode.RATE_LIMITED:
|
|
122
76
|
wait = f" Retry in about {retry:g}s." if retry else " Back off and retry."
|
|
123
77
|
return f"CaptchaKraken: too many requests.{wait}"
|
|
124
78
|
|
|
125
|
-
if code ==
|
|
79
|
+
if code == ErrorCode.ACCOUNT_SUSPENDED:
|
|
126
80
|
return (
|
|
127
81
|
"CaptchaKraken: this account is suspended, so solving is disabled. "
|
|
128
82
|
f"Contact support at {url or _SUPPORT}."
|
|
129
83
|
)
|
|
130
84
|
|
|
131
|
-
if code ==
|
|
85
|
+
if code == ErrorCode.REQUEST_TOO_LARGE:
|
|
132
86
|
return (
|
|
133
87
|
"CaptchaKraken: the screenshot sent for this solve exceeded the request "
|
|
134
88
|
f"size limit. ({message}) Capture the captcha element rather than the "
|
|
135
89
|
"whole page if you are not already."
|
|
136
90
|
)
|
|
137
91
|
|
|
138
|
-
if code in (
|
|
92
|
+
if code in (ErrorCode.MISSING_API_KEY, ErrorCode.INVALID_API_KEY):
|
|
139
93
|
return (
|
|
140
94
|
"CaptchaKraken: the API key was missing or not accepted. Set "
|
|
141
95
|
"CAPTCHA_KRAKEN_API_KEY, or run the CaptchaKraken MCP server's "
|
|
@@ -143,49 +97,18 @@ def _sentence(code: str, message: str, url: Optional[str], retry: Optional[float
|
|
|
143
97
|
f"Manage keys at {url or _DASHBOARD}."
|
|
144
98
|
)
|
|
145
99
|
|
|
146
|
-
if code ==
|
|
100
|
+
if code == ErrorCode.UPSTREAM_UNAVAILABLE:
|
|
147
101
|
return (
|
|
148
102
|
"CaptchaKraken: the solver fleet is temporarily unreachable. This is on "
|
|
149
103
|
"our side, not yours — retry shortly."
|
|
150
104
|
)
|
|
151
105
|
|
|
152
|
-
#
|
|
153
|
-
# are separate codes because the fix is completely different: one is a
|
|
154
|
-
# licence to obtain, the other is a fleet that has not started serving yet
|
|
155
|
-
# and nothing for the caller to do. Generic prose collapses them into "the
|
|
156
|
-
# solve failed", which is the reading that sends someone to buy a licence
|
|
157
|
-
# they already hold.
|
|
158
|
-
if code == "model_not_licensed":
|
|
159
|
-
return (
|
|
160
|
-
"CaptchaKraken: the model this request named is licensed, and this "
|
|
161
|
-
"account is not licensed for it. The request was refused rather than "
|
|
162
|
-
"answered by a different model — a silent substitution would be a "
|
|
163
|
-
"score you could not explain. Unset CAPTCHA_LORA_NAME (or the "
|
|
164
|
-
f"client's `model`) to use the standard hosted model. {message} "
|
|
165
|
-
f"Licensing: {url or _SUPPORT}."
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
if code == "model_not_serving":
|
|
169
|
-
return (
|
|
170
|
-
"CaptchaKraken: this account IS licensed for the model it named, but "
|
|
171
|
-
"the fleet is not serving it yet. Nothing is wrong with your account "
|
|
172
|
-
"and there is nothing to buy. Unset CAPTCHA_LORA_NAME (or the "
|
|
173
|
-
f"client's `model`) to use the standard hosted model meanwhile. {message}"
|
|
174
|
-
)
|
|
175
|
-
|
|
176
|
-
# `unrecognized_prompt`, `invalid_request`, and anything added after this was
|
|
177
|
-
# written. The server's own message is the best thing available, so it is
|
|
178
|
-
# carried through verbatim rather than replaced with a guess.
|
|
106
|
+
# Anything added after this was written: the server's message is the best thing available.
|
|
179
107
|
tail = f" See {url}." if url else ""
|
|
180
108
|
return f"CaptchaKraken: {message}{tail}"
|
|
181
109
|
|
|
182
110
|
|
|
183
111
|
def from_response(resp: Any, url: str) -> Exception:
|
|
184
|
-
"""Build the exception for a non-OK response, hosted or self-hosted.
|
|
185
|
-
|
|
186
|
-
Returns rather than raises so the caller's `raise` keeps the traceback
|
|
187
|
-
anchored at the request site, where it is useful.
|
|
188
|
-
"""
|
|
189
112
|
body_text = (getattr(resp, "text", "") or "")[:300]
|
|
190
113
|
status = getattr(resp, "status_code", None)
|
|
191
114
|
|
|
@@ -194,7 +117,7 @@ def from_response(resp: Any, url: str) -> Exception:
|
|
|
194
117
|
parsed = resp.json()
|
|
195
118
|
if isinstance(parsed, dict) and isinstance(parsed.get("error"), dict):
|
|
196
119
|
error = parsed["error"]
|
|
197
|
-
except Exception:
|
|
120
|
+
except Exception:
|
|
198
121
|
error = {}
|
|
199
122
|
|
|
200
123
|
code = error.get("code")
|
|
@@ -210,8 +133,7 @@ def from_response(resp: Any, url: str) -> Exception:
|
|
|
210
133
|
retry_after_seconds=retry,
|
|
211
134
|
)
|
|
212
135
|
|
|
213
|
-
# No gateway envelope: a local vLLM, a proxy
|
|
214
|
-
# page. Keep the pre-existing message so self-hosted debugging is unchanged.
|
|
136
|
+
# No gateway envelope: a local vLLM, a proxy, or an HTML error page. Self-hosted debugging is unchanged.
|
|
215
137
|
hint = ""
|
|
216
138
|
if status in (401, 403):
|
|
217
139
|
hint = " — check CAPTCHA_KRAKEN_API_KEY is set and forwarded to the CLI"
|