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
@@ -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
- """OpenAI-compatible endpoint, e.g. http://localhost:8000/v1.
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
- """Parse the file the MCP server's signup flow writes.
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
- def _base_url_from_credentials_file() -> str:
154
- """The endpoint from the credentials file, or "" if it names none.
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
- A bare-token file deliberately yields nothing here: it carries no endpoint,
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
- """`field` from the entry of the model this process will actually serve.
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 # local: prompts imports nothing from config
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: # noqa: BLE001 — a broken registry falls back, never raises
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
- """The served LoRA name the client sends as the `model` field."""
255
- return os.getenv("CAPTCHA_LORA_NAME") or _registry_default("lora_name") \
256
- or pinned()["lora_name"]
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
- The solver talks to an OpenAI-compatible endpoint that may be a local vLLM the
4
- user started themselves or the hosted CaptchaKraken gateway. Those two fail very
5
- differently, and until this module existed both were reported the same way:
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
- # Where a user with no `resolution_url` in hand should be sent. Only used as a
31
- # fallback — the server almost always supplies a deep link that is better than
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
- """Seconds from the `Retry-After` header, if it is the numeric form.
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: # noqa: BLE001 — a mapping-ish object is all we assume
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
- """The user-facing line for a known code.
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 == "solve_abandoned":
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 == "rate_limited":
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 == "account_suspended":
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 == "request_too_large":
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 ("missing_api_key", "invalid_api_key"):
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 == "upstream_unavailable":
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
- # The two LICENSED-MODEL refusals. Both are 'you named a model', and they
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: # noqa: BLE001 — a non-JSON body is the self-hosted case
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 in between, or an HTML error
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"