captchakraken 2.9.0 → 3.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 (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 +131 -708
  28. package/dist/solver.js +1319 -3382
  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 +77 -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 +1282 -3101
  60. package/python/src/captchakraken/planner.py +65 -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,12 +1,3 @@
1
- """
2
- ActionPlanner (v2) — talks to local vLLM serving the `captcha` LoRA over the
3
- OpenAI-compatible /v1/chat/completions endpoint.
4
-
5
- v1 supported transformers/vllm-local/gemini/openrouter and a whole tool-using
6
- planner with detect/segment/drag-refine; that code is preserved on the
7
- `v1-old-architecture` branch.
8
- """
9
-
10
1
  import base64
11
2
  import io
12
3
  import json
@@ -21,46 +12,19 @@ import requests
21
12
  from PIL import Image
22
13
 
23
14
  from . import config, errors, prompts
15
+ from .kinds import ActionKind, PixelAnswerKind, PromptFamily, RetryMode
24
16
  from .server_manager import ensure_server
25
- from .timing import timed
26
17
 
27
18
  DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
28
19
 
29
- # The pixel floor the adapters are TRAINED at, and therefore the floor an image
30
- # has to clear before it is worth sending.
31
- #
32
- # Training exports `MIN_PIXELS=200704` (448², in the training repo's
33
- # the training pipeline), so anything smaller is enlarged before the vision
34
- # encoder sees it. Serving did none of that: vLLM runs with no
35
- # `--mm-processor-kwargs` and this client used to re-encode the file unchanged,
36
- # so a small captcha arrived at a geometry the model was never tuned on. It
37
- # fails as plausible-but-wrong coordinates, never as an error — measured on real
38
- # a GeeTest v3 slider captures (277x285), predictions landed 80-105 px from the
39
- # hand label at native size and 1-4 px away once upscaled.
40
- #
41
- # A FLOOR, NOT A RESIZE. Images already above it are passed through byte-for-byte;
42
- # re-encoding every screenshot would spend time and fidelity on the types that
43
- # were never affected.
44
- #
45
- # Unconditional as a DEFAULT, because the deployed v1.1 adapter improves under
46
- # it too (mean error ~40 px native vs ~4 px upscaled). But it is no longer a
47
- # constant: `models.json` may declare a `pixel_budget` per model, because the
48
- # band an adapter TRAINED under is a property of that adapter, exactly like its
49
- # prompt generation. See prompts.pixel_budget.
20
+ # A floor, not a resize, and the AREA is clamped rather than the sides: predictions on a 277x285 GeeTest
21
+ # board landed 80-105px from the hand label at native size and 1-4px once upscaled, and squashing the
22
+ # aspect moves every tile centre.
50
23
  MIN_PIXELS = 448 * 448
51
24
 
52
25
 
53
26
  def _encode_image(path: str,
54
27
  budget: "Optional[prompts.PixelBudget]" = None) -> tuple:
55
- """`(mime, base64)` for one image, fitted into `budget`'s pixel band.
56
-
57
- Area is clamped, never dimensions: aspect ratio is the geometry of a grid
58
- puzzle, and squashing it moves every tile centre — fatal when the answer is
59
- a coordinate on a normalized 0-1000 scale.
60
-
61
- Anything Pillow cannot open is passed through untouched — this is a
62
- preprocessing nicety, and it must never be the reason a solve fails.
63
- """
64
28
  budget = budget or prompts.pixel_budget(None)
65
29
  with open(path, "rb") as f:
66
30
  raw = f.read()
@@ -68,12 +32,10 @@ def _encode_image(path: str,
68
32
  try:
69
33
  im = Image.open(io.BytesIO(raw))
70
34
  width, height = im.size
71
- except Exception: # noqa: BLE001 — unreadable/animated: send it as-is
35
+ except Exception:
72
36
  return mime, base64.b64encode(raw).decode()
73
37
  cap = budget.maximum
74
38
  if cap and width * height > cap:
75
- # floor, not ceil: rounding up here can land back OVER the cap, which
76
- # is the mirror of the bug the floor branch below guards against.
77
39
  scale = math.sqrt(cap / (width * height))
78
40
  im = im.convert("RGB").resize(
79
41
  (max(1, int(width * scale)), max(1, int(height * scale))),
@@ -83,8 +45,6 @@ def _encode_image(path: str,
83
45
  return "image/png", base64.b64encode(buf.getvalue()).decode()
84
46
  if width * height >= budget.minimum:
85
47
  return mime, base64.b64encode(raw).decode()
86
- # ceil, not round: rounding both sides down lands just under the floor
87
- # (442x454 = 200,668 for a 277x285 capture) and defeats the whole point.
88
48
  scale = math.sqrt(budget.minimum / (width * height))
89
49
  im = im.convert("RGB").resize(
90
50
  (math.ceil(width * scale), math.ceil(height * scale)), Image.BICUBIC)
@@ -92,98 +52,39 @@ def _encode_image(path: str,
92
52
  im.save(buf, "PNG")
93
53
  return "image/png", base64.b64encode(buf.getvalue()).decode()
94
54
 
95
- # Header the fleet's the gateway front (on the reverse-proxy EC2) routes on. When a
96
- # caller sets CAPTCHA_REQUEST_PRIORITY to a positive int, every request carries
97
- # `X-JH-Priority: <n>`; the gateway sends anything above its threshold (5) straight
98
- # to the backup GPUs so it never competes with production traffic on the main
99
- # 5090. The tier-2 CI gate sets this — its traffic is throwaway and must stay
100
- # off the primary.
101
- #
102
- # Deliberately a HEADER, not vLLM's request-body `priority` field: that field is
103
- # lower-is-higher and already means captcha=0 / apply=100 / label=200 on the
104
- # priority-scheduled primary, so a value like 10 would MISORDER there (it would
105
- # outrank apply/label). Routing is a fleet concern, kept out of the model's
106
- # scheduler.
55
+ # A header, deliberately not vLLM's body `priority`: that field is lower-is-higher and already means
56
+ # captcha=0 / apply=100 / label=200 on the primary, so a 10 there would misorder. The gateway routes >5 to
57
+ # the backup GPUs; tier-2 CI sets it.
107
58
  _PRIORITY_HEADER = "X-JH-Priority"
108
59
  _PRIORITY_ENV = "CAPTCHA_REQUEST_PRIORITY"
109
60
 
110
- # Hosted-API metadata. Both are OPTIONAL and absent unless deliberately set, so
111
- # self-hosted users never send them and are unaffected.
112
- #
113
- # X-CK-Client — which integration issued this solve (e.g. "camoufox/0.4.11").
114
- # camoufox sets CAPTCHA_KRAKEN_CLIENT in the env it spawns the solver with, so
115
- # the gateway can account camoufox-attributed usage separately. Attribution
116
- # ONLY: it is caller-supplied and therefore never priced on (the gateway
117
- # derives billable puzzle class from the request body instead).
118
- # X-CK-Session — groups the 1..N inference rounds of ONE captcha into a single
119
- # billable attempt. The JS driver mints a UUID per solve() and reuses it for
120
- # every CLI invocation in that solve, which is what lets the gateway cap an
121
- # attempt's billable rounds instead of charging per round without limit.
122
61
  _CLIENT_HEADER = "X-CK-Client"
123
62
  _CLIENT_ENV = "CAPTCHA_KRAKEN_CLIENT"
63
+ # One id per solve lets the gateway cap an attempt's billable rounds.
124
64
  _SESSION_HEADER = "X-CK-Session"
125
65
  _SESSION_ENV = "CAPTCHA_KRAKEN_SESSION"
126
66
 
127
- # Report whether the widget accepted, once per solve. "0" turns it off.
128
- #
129
- # ON by default because the report is what makes a failed solve improvable, and
130
- # because it is the only way a hosted account's failures can be found at all.
131
- # Opting out here stops the CLIENT sending it; `captureOptOut` on the account is
132
- # the server-side half, and either one alone is enough to keep nothing.
67
+ # On by default; `captureOptOut` is the server half. Tier 3 sets it to 0 for ~100 solves a run.
133
68
  _REPORT_OUTCOME_ENV = "CAPTCHA_REPORT_OUTCOME"
134
69
 
135
- # Arbitrary extra headers, as "Name: value" pairs separated by newlines or
136
- # commas. Empty and absent by default.
137
- #
138
- # WHY THIS EXISTS. Some endpoints sit behind a gate that is not the API key —
139
- # our own dev gateway (api.dev.captchakraken.com) wants X-CK-Dev-Auth, and a
140
- # corporate egress proxy may want its own token. Without this the published
141
- # client simply cannot reach such an endpoint, which meant the dev environment
142
- # could not exercise the real client path at all: the thing dev exists to
143
- # rehearse was the one thing it could not do.
144
- #
145
- # IT CANNOT OVERWRITE Authorization, Content-Type, OR THE X-CK-* HEADERS. Those
146
- # carry the credential and the billing attribution. Allowing an env var to
147
- # rewrite Authorization would turn a stray export into "your key is not the key
148
- # being charged", and allowing it to rewrite X-CK-Session would let a caller
149
- # escape the per-attempt billing cap by pinning one session id forever.
70
+ # Extra headers may not rewrite these: pinning one X-CK-Session forever would escape the per-attempt billing cap.
150
71
  _EXTRA_HEADERS_ENV = "CAPTCHA_KRAKEN_EXTRA_HEADERS"
151
72
  _PROTECTED_HEADERS = frozenset(
152
73
  {"authorization", "content-type", _CLIENT_HEADER.lower(), _SESSION_HEADER.lower()}
153
74
  )
154
75
 
155
- # These values reach the wire verbatim from the environment, so they are
156
- # sanitized rather than trusted: a CR/LF would otherwise splice arbitrary
157
- # headers into the upstream request.
158
76
  _HEADER_VALUE_MAX = 128
159
77
 
160
78
 
161
- # RFC 7230 token characters. Anything else is not a header name.
162
79
  _VALID_HEADER_NAME = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
163
80
 
164
81
 
165
82
  def _clean_header_value(raw: str) -> str:
166
- """Printable-ASCII, length-capped header value ("" when nothing survives)."""
83
+ # CR/LF would splice arbitrary headers into the request.
167
84
  return "".join(c for c in raw.strip() if 0x20 <= ord(c) < 0x7F)[:_HEADER_VALUE_MAX]
168
85
 
169
86
 
170
87
  def _extra_headers(raw: str) -> Dict[str, str]:
171
- """Parse CAPTCHA_KRAKEN_EXTRA_HEADERS into a header dict.
172
-
173
- Accepts "Name: value" pairs separated by newlines or commas:
174
-
175
- CAPTCHA_KRAKEN_EXTRA_HEADERS='X-CK-Dev-Auth: hunter2'
176
- CAPTCHA_KRAKEN_EXTRA_HEADERS=$'X-One: a\nX-Two: b'
177
-
178
- Malformed entries are DROPPED, never guessed at. A pair with no colon is
179
- not a header, and inventing one from it would put an attacker-shaped string
180
- on the wire. Names are restricted to the RFC 7230 token characters for the
181
- same reason the values are sanitized: a CR/LF in either would splice extra
182
- headers into the request.
183
-
184
- Protected headers are refused, so this cannot be used to replace the
185
- credential or the billing attribution.
186
- """
187
88
  out: Dict[str, str] = {}
188
89
  if not raw.strip():
189
90
  return out
@@ -206,17 +107,7 @@ def _extra_headers(raw: str) -> Dict[str, str]:
206
107
 
207
108
 
208
109
  def routing_headers(env=None) -> Dict[str, str]:
209
- """Fleet-routing + hosted-API headers derived from the environment.
210
-
211
- Empty by default. Split out and env-injectable so it can be unit-tested
212
- without a live server. A missing or non-integer CAPTCHA_REQUEST_PRIORITY
213
- yields no header at all — an unset/garbage value must never silently tag
214
- traffic for the backups.
215
-
216
- Each header is derived independently: a malformed priority must not suppress
217
- the attribution headers, or one typo would silently make a camoufox solve
218
- look like direct traffic and understate the partner's revenue share.
219
- """
110
+ """Each header is derived on its own: a malformed priority must not drop the attribution headers and understate a partner's revenue share."""
220
111
  env = os.environ if env is None else env
221
112
  headers: Dict[str, str] = {}
222
113
 
@@ -237,88 +128,23 @@ def routing_headers(env=None) -> Dict[str, str]:
237
128
  return headers
238
129
 
239
130
 
240
- # ── Prompt constants ────────────────────────────────────────────────────────
241
- #
242
- # These are ALIASES for the newest generation's built-ins, kept so that code and
243
- # tests written against the old module-level constants keep working. They are
244
- # NOT what a solve sends. The prompt a model gets is resolved per model
245
- # (`prompts.resolve`), because a model answers in whatever schema its prompt
246
- # asks for — sending a version-1 model the version-2 prompt does not error, it
247
- # silently degrades every puzzle. See prompts.py and models.json.
248
- #
249
- # The text itself lives in prompts.py and nowhere else. Two copies of a prompt
250
- # is the drift this module already shipped once (2026-07-18).
251
131
  _LATEST = prompts.builtin(prompts.LATEST_PROMPT_VERSION)
252
132
 
253
133
  SELECT_GRID_PROMPT = _LATEST.grid_template
254
134
 
255
135
 
256
- # Non-grid click/drag puzzles, newest generation. Alias — see the note above.
257
136
  PIXEL_ACTION_PROMPT = _LATEST.action_prompt
258
137
 
259
138
 
260
- # Animated puzzles: the challenge is recorded, sliced into keyframes
261
- # (`keyframes.py`) and sent as an ORDINARY MULTI-IMAGE request — one image per
262
- # keyframe, all in one context so the model can compare them. The answer carries
263
- # a `frame` naming which keyframe it acted on, and the page driver waits for the
264
- # live widget to look like that frame before pressing the mouse down.
265
- #
266
- # Alias for the newest generation — see the note above. Generation 1 has NO
267
- # animated prompt (the family did not exist), which is why resolution is per
268
- # model rather than per client.
269
- VIDEO_ACTION_PROMPT_TEMPLATE = _LATEST.video_template
270
-
271
-
272
- def video_action_prompt(n_keyframes: int) -> str:
273
- """The prompt for a challenge served as `n_keyframes` stills.
274
-
275
- The count is in the text because the model has no other way to know how many
276
- images arrived — frame identities live in the prompt, not in the image payload.
277
- Mirrors `instructions.video_instruction` in the training repo exactly.
278
- """
279
- n = int(n_keyframes)
280
- if n < 1:
281
- raise ValueError(f"a keyframe request needs at least one frame, got {n_keyframes}")
282
- listing = ", ".join(f"frame {i}" for i in range(1, n + 1))
283
- return VIDEO_ACTION_PROMPT_TEMPLATE.format(n=n, listing=listing)
284
-
285
-
286
- #: Sampling temperature per RE-ASK of a board the vendor refused, first entry
287
- #: first. THE ONE COPY: the JS port reaches this through `CAPTCHA_RESAMPLE_LEVEL`
288
- #: rather than carrying its own numbers, because a schedule written down twice is
289
- #: a schedule the two ports can disagree about — and the driver gate drives both
290
- #: and averages them.
291
- #:
292
- #: GREEDY ONLY, AND THAT IS A MEASUREMENT, NOT AN OVERSIGHT. Heating the re-ask
293
- #: was tried: `(0.0, 0.35, 0.7)`, escalating each time a board came back with the
294
- #: answer it had already given. On five puzzle types that a greedy driver passes,
295
- #: the escalating one scored 0/5 against 4/5, and the median FAILED attempt cost
296
- #: 24.0s against 13.9s. Two things went wrong and they compound:
297
- #:
298
- #: 1. A hotter sample is a WORSE answer. The greedy read is the best single
299
- #: guess this model has; the schedule spends rounds walking away from it.
300
- #: 2. It defeats the give-up rule. `_no_progress_rounds` counts IDENTICAL
301
- #: answers, so making each re-ask differ resets the counter — the attempt
302
- #: stops bailing at round 3 and runs to the round ceiling instead. Slower
303
- #: failures, not fewer.
304
- #:
305
- #: The plumbing stays because the LEVEL is the right thing to travel; what was
306
- #: wrong was the trigger. A re-ask should be driven by the vendor actually
307
- #: refusing the answer — read off the network response — and not by the model
308
- #: agreeing with itself, which is only ever evidence that decoding is
309
- #: deterministic. Add temperatures here when that signal exists to gate them.
139
+
140
+
141
+ # Greedy only, by measurement: (0.0, 0.35, 0.7) scored 0/5 against 4/5 and made the median failed attempt
142
+ # 24.0s against 13.9s, because a differing re-ask resets the no-progress counter. The level plumbing stays for
143
+ # a network-refusal trigger. Lives here once so the two ports cannot drift (TRIBAL_KNOWLEDGE.md).
310
144
  RESAMPLE_TEMPERATURES = (0.0,)
311
145
 
312
146
 
313
147
  def sampling_for_level(level: int) -> Dict[str, Any]:
314
- """Decode overrides for the `level`-th re-ask of one board.
315
-
316
- Level 0 is greedy and returns {}, so a first look is byte-identical to what
317
- this client has always sent. Past that it carries a temperature AND a seed:
318
- the temperature is what makes a second sample possible at all, and the seed
319
- is what makes two re-asks at one temperature two samples rather than one
320
- sample twice.
321
- """
322
148
  temps = RESAMPLE_TEMPERATURES or (0.0,)
323
149
  temp = temps[min(max(level, 0), len(temps) - 1)]
324
150
  if not temp:
@@ -327,13 +153,6 @@ def sampling_for_level(level: int) -> Dict[str, Any]:
327
153
 
328
154
 
329
155
  def _sampling_from_env() -> Dict[str, Any]:
330
- """The JS port's re-ask level, which arrives as an env var.
331
-
332
- That port drives its own round loop and spawns this CLI once per round, so
333
- a fresh process has no memory of how many times the board has already been
334
- read. Passing the LEVEL rather than the temperature keeps the schedule in
335
- one place — see `RESAMPLE_TEMPERATURES`.
336
- """
337
156
  raw = os.environ.get("CAPTCHA_RESAMPLE_LEVEL", "").strip()
338
157
  if not raw:
339
158
  return {}
@@ -343,120 +162,59 @@ def _sampling_from_env() -> Dict[str, Any]:
343
162
  return {}
344
163
 
345
164
 
165
+ #: The ceiling on one ask, and the old hardcoded value. Measured why it matters: against the hosted
166
+ #: endpoint a single request hung and returned "the write operation timed out" after ~124s, inside a
167
+ #: solve whose whole budget was 45s + 29s of recording — the attempt ran 143s. A timeout longer than
168
+ #: the budget it sits inside cannot protect it, so callers with a deadline pass their own.
169
+ DEFAULT_REQUEST_TIMEOUT_S: float = 120.0
170
+ #: Never ask for less than this: a keyframe ask carries six images and a short timeout would turn a
171
+ #: busy endpoint into a guaranteed failure rather than a slow success.
172
+ MIN_REQUEST_TIMEOUT_S: float = 10.0
173
+
174
+
346
175
  class ActionPlanner:
347
- """Thin client for the vLLM `captcha` LoRA."""
348
176
 
349
177
  def __init__(
350
178
  self,
351
179
  model: Optional[str] = None,
352
- debug_callback: Optional[Any] = None,
353
180
  base_url: Optional[str] = None,
354
181
  api_key: Optional[str] = None,
355
- expert: Optional[str] = None,
182
+ expert: Optional[PromptFamily] = None,
356
183
  **_: Any,
357
184
  ):
358
- self.debug_callback = debug_callback
359
185
  self.token_usage: List[Dict[str, Any]] = []
360
186
 
361
- # All model/endpoint defaults come from `config` (env-overridable), so
362
- # the planner itself is model-agnostic — swap models via env, not code.
363
187
  self.model = model or config.lora_name()
364
188
  self.base_url = base_url or config.base_url()
365
189
  self.api_key = api_key or config.api_key()
366
- #: Decode overrides merged into every request payload, or empty.
367
- #:
368
- #: The page driver sets this while RE-ASKING a board the vendor already
369
- #: refused, so that the second look is a second SAMPLE rather than the
370
- #: same arithmetic run twice — see
371
- #: `PageSolverConfig.resample_temperatures`. Empty by default and
372
- #: cleared the moment a fresh board arrives, because greedy is the best
373
- #: single guess and every solve that works today works on it.
374
190
  self.sampling: Dict[str, Any] = {}
375
- # Prompts follow the MODEL, not this client's release. Resolve by the
376
- # served name first (a hosted endpoint serves `captcha`, not a repo id,
377
- # and models.json maps the alias back), falling back to the adapter repo
378
- # id for a self-hosted deploy that serves under its own name.
379
- #
380
- # Doing this ONCE per planner rather than per request: resolution can
381
- # touch the Hub, and a per-request lookup would put a network call in
382
- # front of every round of every solve.
191
+ #: Seconds a single ask may take. The CALLER owns this: a solve has a budget, and a request
192
+ #: that outlives it cannot be cancelled once it is in flight — the deadline is only read
193
+ #: between steps. Left alone it is the old ceiling; `PageSolver` lowers it to what remains.
194
+ self.request_timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S
383
195
  _prompt_key = (self.model if prompts.canonical_model_id(self.model)
384
196
  else config.lora_adapter())
385
197
  self.prompts = prompts.resolve(_prompt_key)
386
- # Resolution follows the MODEL for the same reason prompts do, and off
387
- # the same key: an adapter reads a puzzle at the pixel band it trained
388
- # under. Resolved once per planner — see the note above.
389
198
  self.pixel_budget = prompts.pixel_budget(_prompt_key)
390
- # WHICH ADAPTER answers each prompt family. Empty for every model that
391
- # is not routed, which is every model published so far — `_model_for`
392
- # then returns `self.model` for every request and the wire is unchanged.
393
- #
394
- # Resolved once, like the two above, and for a stronger reason: the pin
395
- # is validated here, so `--expert grid` against a single-adapter model
396
- # fails when the solver is BUILT rather than on the first grid it meets.
397
199
  self.expert = (expert if expert is not None
398
200
  else prompts.expert_pin())
399
201
  self.experts = prompts.experts(_prompt_key)
400
202
  if self.expert:
401
- # Raises on an unknown family, and on a pin against an unrouted
402
- # model. Both are configuration errors, and a benchmark that
403
- # quietly measured the generalist while reporting an expert is the
404
- # exact class of silent mispairing models.json exists to prevent.
405
203
  prompts.route(_prompt_key, None, pin=self.expert)
406
- # Auto-start a local vLLM server on the first request if one isn't up
407
- # (no-op for a healthy or remote endpoint). Guarded so we only try once.
408
204
  self._server_ensured = False
409
- # ONE connection, reused for every round of every solve this planner
410
- # serves. `requests.post` opens a fresh TCP connection each call — and
411
- # to a hosted endpoint a TLS handshake with it. Measured against the
412
- # gate's own endpoint: 258ms per request connectionless vs 144ms
413
- # pooled, so every inference was paying ~110ms to re-dial a server it
414
- # had just finished talking to. A multi-round grid pays it per round.
415
- #
416
- # The planner is per-PageSolver and a PageSolver is reusable across
417
- # solves, so a caller who keeps one keeps the connection warm too.
418
205
  self._http = requests.Session()
419
- # Latched off by the first 404 — see `report_outcome`.
420
206
  self._outcome_supported = True
421
207
 
422
- def _model_for(self, family: Optional[str]) -> str:
423
- """The `model` string for a request in `family`.
424
-
425
- `self.model` whenever the model is not routed, so a caller may pass a
426
- family unconditionally. A routed model with no expert for this family
427
- degrades to the generalist — never to another expert, never to an
428
- error (docs/MOE_LORA_DESIGN.md §11).
429
- """
208
+ def _model_for(self, family: Optional[PromptFamily]) -> str:
430
209
  if not self.experts:
431
210
  return self.model
432
211
  return prompts.route(self.model, family, pin=self.expert) or self.model
433
212
 
434
- # ── the solve's own verdict, sent back ─────────────────────────────────
435
- #
436
- # THE ONE THING THE SERVER CANNOT SEE. A wrong answer reaches the gateway as
437
- # a 200 with well-formed JSON in it; only this driver watched the widget.
438
- # Without this report the hosted API cannot tell a solved captcha from a
439
- # failed one, and the failures — the exact boards the model is worst at, on
440
- # real sites — are the one dataset that cannot be collected any other way.
441
- #
442
- # BEST-EFFORT, ALWAYS. It runs after the solve is over and its result is
443
- # already decided, so nothing it does can change the answer the caller gets.
444
- # Every failure is swallowed: a self-hosted vLLM has no such route, a
445
- # network blip is not the caller's problem, and an exception here would turn
446
- # a successful solve into a raised one.
447
- #
448
- # ONE 404 IS ENOUGH. A self-hosted endpoint answers 404 forever, so the
449
- # first one latches this off for the life of the planner rather than paying
450
- # a round trip per solve to be told the same thing again.
451
213
  _OUTCOME_PATH = "/solve-outcome"
452
214
  _OUTCOME_TIMEOUT_S = 3.0
453
215
 
454
216
  def report_outcome(self, session_id: Optional[str], solved: bool) -> bool:
455
- """Tell the API whether the widget accepted. True if it was delivered.
456
-
457
- Never raises. The return value is for tests and for `--debug`; no caller
458
- should branch on it.
459
- """
217
+ """A 404 disables it for the planner's lifetime: a self-hosted vLLM has no /solve-outcome."""
460
218
  if not session_id or not self._outcome_supported:
461
219
  return False
462
220
  if os.getenv(_REPORT_OUTCOME_ENV, "1") == "0":
@@ -472,11 +230,10 @@ class ActionPlanner:
472
230
  json={"session": session_id, "solved": bool(solved)},
473
231
  timeout=self._OUTCOME_TIMEOUT_S,
474
232
  )
475
- except Exception as exc: # noqa: BLE001 — see the note above
233
+ except Exception as exc:
476
234
  self._log(f"outcome report failed: {exc}")
477
235
  return False
478
236
  if resp.status_code == 404:
479
- # Not an error: this endpoint does not serve the route. Stop asking.
480
237
  self._outcome_supported = False
481
238
  self._log("outcome reporting: endpoint has no /solve-outcome; disabled")
482
239
  return False
@@ -487,28 +244,17 @@ class ActionPlanner:
487
244
  def _log(self, message: str) -> None:
488
245
  if DEBUG:
489
246
  print(f"[Planner] {message}", file=sys.stderr)
490
- if self.debug_callback:
491
- self.debug_callback(f"[Planner] {message}")
492
247
 
493
248
  def _chat_with_image(self, prompt: str, image_path: str, max_tokens: int = 512,
494
- family: Optional[str] = None) -> str:
249
+ family: Optional[PromptFamily] = None) -> str:
495
250
  return self._chat_with_images(prompt, [image_path], max_tokens=max_tokens,
496
251
  family=family)
497
252
 
498
253
  def _chat_with_images(
499
254
  self, prompt: str, image_paths: List[str], max_tokens: int = 512,
500
- family: Optional[str] = None,
255
+ family: Optional[PromptFamily] = None,
501
256
  ) -> str:
502
- """One request carrying N images followed by the prompt.
503
-
504
- The images go in ONE message, before the text, in the order given. That
505
- ordering is what makes an animated challenge answerable: the model is told
506
- "you are given N keyframes, in order: frame 1, frame 2, …" and matches those
507
- names to the images positionally. Splitting them across requests, or putting
508
- the text first, would break the correspondence between the numbers in the
509
- prompt and the pictures — and the frame number is the part the page driver
510
- acts on.
511
- """
257
+ """One request per image set, images before the text in one message: the model reads frames positionally, and per-frame queries would cost N billable rounds."""
512
258
  if not image_paths:
513
259
  raise ValueError("no images to send")
514
260
 
@@ -530,23 +276,15 @@ class ActionPlanner:
530
276
  ]
531
277
 
532
278
  model = self._model_for(family)
533
- # `sampling` is set by the page driver when it is RE-ASKING a board the
534
- # vendor already refused — see `PageSolverConfig.resample_temperatures`.
535
- # Absent (the normal case, and every first look) it is greedy exactly as
536
- # before, so nothing about a solve that works today changes.
537
279
  payload = {
538
280
  "model": model,
539
281
  "messages": messages,
540
282
  "temperature": 0,
541
283
  "max_tokens": max_tokens,
542
- # Qwen3.5's reasoning otherwise eats the token budget. `/no_think`
543
- # in the prompt alone is unreliable; disabling at the chat-template
544
- # level is the documented way.
284
+ # Both spellings: Ollama ignores chat_template_kwargs and defaults thinking on; vLLM reads reasoning_effort only when the kwargs are unset.
545
285
  "chat_template_kwargs": {"enable_thinking": False},
286
+ "reasoning_effort": "none",
546
287
  }
547
- # In-process the driver sets `sampling` directly; the JS port drives
548
- # its own loop and spawns this as a CLI, so its level arrives in the
549
- # environment. Explicit beats inherited when both are present.
550
288
  sampling = self.sampling or _sampling_from_env()
551
289
  if sampling:
552
290
  payload.update(sampling)
@@ -554,20 +292,13 @@ class ActionPlanner:
554
292
  headers = {
555
293
  "Authorization": f"Bearer {self.api_key}",
556
294
  "Content-Type": "application/json",
557
- # Fleet routing: absent unless CAPTCHA_REQUEST_PRIORITY is set (see
558
- # routing_headers). Low-priority batch traffic is steered to the
559
- # backup GPUs by the gateway front on this header.
560
295
  **routing_headers(),
561
296
  }
562
297
 
563
- # Hands-off server: before the very first request, make sure something
564
- # is listening (auto-start a local vLLM if needed; no-op if it's already
565
- # up or the endpoint is remote). Best-effort — a failure here surfaces as
566
- # a normal connection error on the request below.
567
298
  if not self._server_ensured:
568
299
  try:
569
300
  ensure_server(self.base_url)
570
- except Exception as e: # noqa: BLE001 — don't mask the real request error
301
+ except Exception as e:
571
302
  self._log(f"ensure_server: {e}")
572
303
  self._server_ensured = True
573
304
 
@@ -575,19 +306,9 @@ class ActionPlanner:
575
306
  self._log(f"POST {url} model={model} max_tokens={max_tokens} "
576
307
  f"images={len(parts)}")
577
308
 
578
- with timed("planner.chat"):
579
- resp = self._http.post(url, headers=headers, json=payload, timeout=120)
580
-
581
- # Surface auth / billing / server errors as something the reader can act
582
- # on, instead of letting resp.json() blow up with a cryptic "Expecting
583
- # value: line 1 column 1" on a non-JSON response.
584
- #
585
- # `errors.from_response` decides which of the two worlds we are in by
586
- # looking for the gateway's `error.code`: present means the hosted API
587
- # refused us and there is a real explanation to give (out of credits,
588
- # rate limited, attempt abandoned); absent means a local vLLM or a proxy,
589
- # and the old message — including the bearer-token hint on 401/403 — is
590
- # reproduced unchanged.
309
+ resp = self._http.post(url, headers=headers, json=payload,
310
+ timeout=self.request_timeout_s or DEFAULT_REQUEST_TIMEOUT_S)
311
+
591
312
  if not resp.ok:
592
313
  raise errors.from_response(resp, url)
593
314
 
@@ -626,17 +347,9 @@ class ActionPlanner:
626
347
  else:
627
348
  return None
628
349
 
629
- # strict=False tolerates unescaped control chars INSIDE strings — the
630
- # model routinely emits coordinates as pretty-printed strings with a
631
- # literal newline, e.g. "click": ["277,\n 728", ...], which strict JSON
632
- # rejects. Without this the whole response fails to parse and a solvable
633
- # click puzzle is dropped as "unsupported".
634
350
  try:
635
351
  return json.loads(text[start:end], strict=False)
636
352
  except json.JSONDecodeError:
637
- # The model sometimes truncates (hit max_tokens) or over-nests its
638
- # pretty-printed JSON, leaving brackets unclosed. Repair by balancing
639
- # from the first opener to the end of the raw text.
640
353
  repaired = ActionPlanner._balance_json(text[start:])
641
354
  if repaired is not None:
642
355
  try:
@@ -647,9 +360,6 @@ class ActionPlanner:
647
360
 
648
361
  @staticmethod
649
362
  def _balance_json(text: str) -> Optional[str]:
650
- """Close any brackets/braces the model left open (string-aware), so a
651
- truncated ``{"action": {"points": [[1,2],`` still parses. Returns None if
652
- there was nothing to balance."""
653
363
  stack: List[str] = []
654
364
  in_str = False
655
365
  esc = False
@@ -677,7 +387,6 @@ class ActionPlanner:
677
387
  out = text
678
388
  if in_str:
679
389
  out += '"'
680
- # Drop a dangling comma / partial token before the closers.
681
390
  out = out.rstrip().rstrip(",")
682
391
  for opener in reversed(stack):
683
392
  out += "}" if opener == "{" else "]"
@@ -688,16 +397,8 @@ class ActionPlanner:
688
397
  image_path: str,
689
398
  rows: int,
690
399
  cols: int,
691
- retry_mode: Optional[str] = None,
400
+ retry_mode: Optional[RetryMode] = None,
692
401
  ) -> List[int]:
693
- """Return the list of 1-indexed cells the model wants to click.
694
-
695
- retry_mode == "missed-tiles": the previous submission was rejected
696
- by the captcha vendor with an under-selection error. Append an
697
- explicit recovery instruction that tells the model the FULL grid
698
- contains at least one matching tile it didn't pick last time. This
699
- nudges it off the "I already covered everything" attractor.
700
- """
701
402
  total = rows * cols
702
403
  if rows == 4 and cols == 4:
703
404
  grid_hint = "Hint: Single large image split into tiles. Select ALL parts."
@@ -707,7 +408,7 @@ class ActionPlanner:
707
408
  prompt = self.prompts.grid_prompt(
708
409
  rows=rows, cols=cols, grid_hint=grid_hint
709
410
  )
710
- if retry_mode == "missed-tiles":
411
+ if retry_mode == RetryMode.MISSED_TILES:
711
412
  prompt = (
712
413
  prompt
713
414
  + "\n\nIMPORTANT: A previous submission was rejected because not all "
@@ -717,29 +418,16 @@ class ActionPlanner:
717
418
  "including any matches you may have overlooked."
718
419
  )
719
420
  raw = self._chat_with_image(prompt, image_path, max_tokens=128,
720
- family="grid")
421
+ family=PromptFamily.GRID)
721
422
  out = self._normalize_grid(self._parse_json(raw), total)
722
423
  self._log(f"grid selection -> {out}")
723
424
  return out
724
425
 
725
426
  @staticmethod
726
427
  def _normalize_grid(data: Any, total: int) -> List[int]:
727
- """Map the model's grid JSON to a list of 1-indexed cell numbers.
728
-
729
- Accepts the trained shape (a bare JSON array) plus the wrapped forms the
730
- model drifts into: {"target_ids": [...]} and {"action": {"target_ids":
731
- [...]}}. Out-of-range and non-numeric entries are dropped rather than
732
- raising — a single junk element must not cost the whole selection.
733
-
734
- Split out of get_grid_selection so the parse is testable without a
735
- model, mirroring _normalize_pixel.
736
- """
737
428
  if isinstance(data, list):
738
429
  ids = data
739
430
  elif isinstance(data, dict):
740
- # `action` is a dict on the grid path but a bare string ("drag") on
741
- # the pixel path — guard so a mis-routed response returns [] rather
742
- # than raising AttributeError.
743
431
  nested = data.get("action")
744
432
  nested_ids = nested.get("target_ids") if isinstance(nested, dict) else None
745
433
  ids = data.get("target_ids") or nested_ids or []
@@ -757,56 +445,20 @@ class ActionPlanner:
757
445
  return out
758
446
 
759
447
  def get_pixel_actions(self, image_path: str, text_mode: bool = False) -> List[Dict[str, Any]]:
760
- """Solve a non-grid click/drag/slide/text puzzle.
761
-
762
- Sends the trained action prompt + image, parses the model's JSON, and
763
- returns a list of normalized actions with all coordinates on a 0–1
764
- scale:
765
-
766
- {"kind": "click", "points": [(x, y), ...]}
767
- {"kind": "drag", "src": (x, y), "dst": (x, y)}
768
- {"kind": "slide", "dst": (x, y)} — puzzle-piece slider
769
- {"kind": "type", "text": "<the code>"} — distorted text
770
-
771
- `text_mode` swaps in the distorted-text prompt. It is chosen by the
772
- DRIVER, from the widget's DOM (a visible text box in the challenge),
773
- because nothing about the picture reliably says "this one is typed" —
774
- BotDetect's warped letters and hCaptcha's "click the matching letter"
775
- look alike to a pixel classifier, and the two want opposite answers.
776
-
777
- Returns [] if the model produced nothing usable. The solver turns these
778
- into ClickAction / DragAction / TypeAction.
779
- """
780
448
  prompt = self.prompts.text_prompt() if text_mode else self.prompts.action_prompt
781
449
  raw = self._chat_with_image(prompt, image_path, max_tokens=512,
782
- family="text" if text_mode else "pixel")
450
+ family=PromptFamily.TEXT if text_mode else PromptFamily.PIXEL)
783
451
  data = self._parse_json(raw)
784
452
  actions = self._normalize_pixel(data)
785
453
  self._log(f"pixel actions -> {actions}")
786
454
  return actions
787
455
 
788
456
  def get_keyframe_actions(self, keyframe_paths: List[str]) -> List[Dict[str, Any]]:
789
- """Solve an ANIMATED challenge from its keyframes.
790
-
791
- Same normalized actions as `get_pixel_actions`, each additionally carrying
792
- `"frame"`: the 1-based keyframe the model chose to act on, or None if it did
793
- not name a usable one.
794
-
795
- The frame is not decoration. On these puzzles the target is only there part
796
- of the time, so the coordinates are only correct while the widget looks like
797
- the frame they were read off. The page driver holds the mouse until it does.
798
- An action with `frame=None` is still returned — the caller decides whether to
799
- click blind or give up, and dropping it here would look identical to "the
800
- model had nothing to say".
801
- """
802
457
  if not keyframe_paths:
803
458
  return []
804
- # Per model, not per client: generation 1 has no animated prompt at all,
805
- # so this raises a clear error there instead of sending a v1 model a
806
- # keyframe request it was never trained to read.
807
459
  prompt = self.prompts.video_prompt(len(keyframe_paths))
808
460
  raw = self._chat_with_images(prompt, list(keyframe_paths), max_tokens=512,
809
- family="video")
461
+ family=PromptFamily.VIDEO)
810
462
  data = self._parse_json(raw)
811
463
  actions = self._normalize_pixel(data)
812
464
  frame = self._normalize_frame(data, len(keyframe_paths))
@@ -817,14 +469,7 @@ class ActionPlanner:
817
469
 
818
470
  @staticmethod
819
471
  def _normalize_frame(data: Any, n_keyframes: int) -> Optional[int]:
820
- """The keyframe number the model chose, or None.
821
-
822
- Out-of-range is treated as "did not choose" rather than clamped. Clamping a
823
- 7 to 6 would invent an intent the model never had, and the driver would then
824
- wait for a picture the answer was not about — a slow failure that looks like
825
- a flaky page. Tolerates the nested shape the model drifts into
826
- ({"action": {"frame": n}}), the same way the click/drag parsing does.
827
- """
472
+ """Out of range is None, not clamped: clamping a 7 to 6 would invent an intent."""
828
473
  if not isinstance(data, dict):
829
474
  return None
830
475
  raw = data.get("frame")
@@ -838,22 +483,13 @@ class ActionPlanner:
838
483
 
839
484
  @staticmethod
840
485
  def _normalize_pixel(data: Any) -> List[Dict[str, Any]]:
841
- """Map the model's 0–1000 click/drag JSON to 0–1 normalized actions.
842
-
843
- Tolerant of the two trained shapes plus a few near-misses:
844
- click: {"action": {"action": "click", "points": [[x, y], ...]}}
845
- or top-level {"points": [...]} / {"action": {"points": [...]}}
846
- drag: {"output": [{"Action": "simulate_drag",
847
- "SourcePosition": {x, y},
848
- "EstimatedPosition": {x, y}}, ...]}
849
- """
486
+ """Every answer shape the model has been seen to produce, oldest last; `kind` is a PixelAnswerKind."""
850
487
  def norm_xy(x: Any, y: Any) -> Optional[tuple]:
851
488
  try:
852
489
  fx, fy = float(x) / 1000.0, float(y) / 1000.0
853
490
  except (TypeError, ValueError):
854
491
  return None
855
492
  if not (0.0 <= fx <= 1.0 and 0.0 <= fy <= 1.0):
856
- # Some outputs use 0–1 already; accept those too.
857
493
  if 0.0 <= float(x) <= 1.0 and 0.0 <= float(y) <= 1.0:
858
494
  fx, fy = float(x), float(y)
859
495
  else:
@@ -861,10 +497,6 @@ class ActionPlanner:
861
497
  return (fx, fy)
862
498
 
863
499
  def flat_numbers(v: Any) -> List[float]:
864
- """Every number anywhere inside v, in order. Handles nested lists,
865
- {x,y} dicts, bare numbers, and numeric strings like "277, 728" (the
866
- model sometimes emits coordinates as strings, occasionally even split
867
- across separate array elements)."""
868
500
  nums: List[float] = []
869
501
  if isinstance(v, bool):
870
502
  return nums
@@ -882,10 +514,6 @@ class ActionPlanner:
882
514
  return nums
883
515
 
884
516
  def coordish(v: Any) -> bool:
885
- """True when v is a non-empty list of COORDINATES (numbers, [x,y]
886
- pairs, {x,y} dicts, or numeric strings) — not text labels like
887
- "dog". Used to decide whether a "click"/"coordinates" array carries
888
- points we should salvage."""
889
517
  if not isinstance(v, (list, tuple)) or not v:
890
518
  return False
891
519
  for e in v:
@@ -902,29 +530,15 @@ class ActionPlanner:
902
530
  if not isinstance(data, dict):
903
531
  return out
904
532
 
905
- # ---- type (CANONICAL — the schema TEXT_INSTRUCTION asks for on the
906
- # distorted-text captchas: {"action": "type", "text": "<the code>"}).
907
- # First, because it is the one answer family with NO coordinate in it:
908
- # every branch below keys off a number, so a typed answer that fell
909
- # through reached the end and normalized to nothing.
910
- # The code is passed through VERBATIM — case and spacing are part of what
911
- # the model read off the image, and "tidying" them submits a different
912
- # answer than the one it gave.
913
533
  act = data.get("action")
914
- if isinstance(act, dict) and act.get("action") == "type":
915
- act = act.get("action") # unwrap {"action": {"action": "type", ...}}
916
- if act == "type" or (isinstance(data.get("text"), str) and "drags" not in data):
534
+ if isinstance(act, dict) and act.get("action") == ActionKind.TYPE:
535
+ act = act.get("action")
536
+ if act == ActionKind.TYPE or (isinstance(data.get("text"), str) and "drags" not in data):
917
537
  container = data if isinstance(data.get("text"), str) else data.get("action")
918
538
  text = container.get("text") if isinstance(container, dict) else None
919
539
  if isinstance(text, str) and text:
920
- return [{"kind": "type", "text": text}]
921
-
922
- # ---- drag (CANONICAL — the schema PIXEL_ACTION_PROMPT asks for and the
923
- # LoRA is trained on, see instructions.py::ACTION_INSTRUCTION):
924
- # {"action":"drag","drags":[{"source","from":[x,y],"destination","to":[x,y]}]}
925
- # Note "action" here is the STRING "drag", not a dict, so the click path
926
- # below never sees it. The legacy/simulate_drag branches that follow stay
927
- # as fallbacks for older adapters.
540
+ return [{"kind": PixelAnswerKind.TYPE, "text": text}]
541
+
928
542
  content_drags = data.get("drags")
929
543
  if content_drags is None and isinstance(data.get("action"), dict):
930
544
  content_drags = data["action"].get("drags")
@@ -937,27 +551,19 @@ class ActionPlanner:
937
551
  snums = flat_numbers(d.get("from"))
938
552
  dnums = flat_numbers(d.get("to"))
939
553
  if len(dnums) < 2:
940
- continue # a piece with nowhere to go is not actionable
554
+ continue
941
555
  dst = norm_xy(dnums[0], dnums[1])
942
556
  if not dst:
943
557
  continue
944
558
  if len(snums) >= 2:
945
559
  src = norm_xy(snums[0], snums[1])
946
560
  if src:
947
- out.append({"kind": "drag", "src": src, "dst": dst})
561
+ out.append({"kind": PixelAnswerKind.DRAG, "src": src, "dst": dst})
948
562
  else:
949
- # SOURCELESS — a puzzle-piece slider. The prompt's "FOR
950
- # PUZZLE PIECE SLIDER PUZZLES" clause tells the model to
951
- # leave the source empty precisely because the piece is not
952
- # what you pick up: the handle is, somewhere else entirely,
953
- # and how far it has to travel is not knowable from the
954
- # picture. Requiring both ends here dropped the one answer
955
- # shape the prompt asks for on every slide puzzle.
956
- out.append({"kind": "slide", "dst": dst})
563
+ out.append({"kind": PixelAnswerKind.SLIDE, "dst": dst})
957
564
  if out:
958
565
  return out
959
566
 
960
- # ---- drag: {"output": [ {simulate_drag ...}, ... ]} ----
961
567
  drags = data.get("output")
962
568
  if isinstance(drags, list) and drags:
963
569
  for d in drags:
@@ -968,34 +574,17 @@ class ActionPlanner:
968
574
  src = norm_xy(sp.get("x"), sp.get("y")) if isinstance(sp, dict) else None
969
575
  dst = norm_xy(ep.get("x"), ep.get("y")) if isinstance(ep, dict) else None
970
576
  if src and dst:
971
- out.append({"kind": "drag", "src": src, "dst": dst})
577
+ out.append({"kind": PixelAnswerKind.DRAG, "src": src, "dst": dst})
972
578
  if out:
973
579
  return out
974
580
 
975
- # ---- drag: {"action": {"simulate_drag": [{source_position,
976
- # destination_position}]}} ----
977
- # The full-puzzle LoRA actually emits drags in this snake_case shape
978
- # (not the prompt's {"output":[{"Action":"simulate_drag",...}]}), and
979
- # packs each coordinate as {"x": [x, y]} — flat_numbers() pulls the pair
980
- # out of that. Without this branch a correctly-solved drag (e.g. hCaptcha
981
- # "drag ONE character to the matching character") is dropped as
982
- # "unsupported".
983
581
  sd = data.get("simulate_drag")
984
582
  if sd is None and isinstance(action := data.get("action"), dict):
985
583
  sd = action.get("simulate_drag")
986
- # The model emits simulate_drag as EITHER a list of drags OR a single
987
- # drag object; normalize the single-object form to a one-element list so
988
- # both parse (the object form was silently dropped as "unsupported").
989
584
  if isinstance(sd, dict):
990
585
  sd = [sd]
991
586
  if isinstance(sd, list) and sd:
992
587
  def _drag_coords(obj: Any, *roles: str) -> List[float]:
993
- # Pull the source/destination coordinate pair regardless of key
994
- # casing or separators — the LoRA freely varies between
995
- # source_position / sourcePosition / SourcePosition and
996
- # destination_position / destinationPosition / EstimatedPosition.
997
- # The coord value may be {"x": [x, y]}, [x, y], {x, y}, or a
998
- # string; flat_numbers() handles all of those.
999
588
  if not isinstance(obj, dict):
1000
589
  return []
1001
590
  for k, v in obj.items():
@@ -1016,15 +605,11 @@ class ActionPlanner:
1016
605
  src = norm_xy(snums[0], snums[1])
1017
606
  dst = norm_xy(dnums[0], dnums[1])
1018
607
  if src and dst:
1019
- out.append({"kind": "drag", "src": src, "dst": dst})
608
+ out.append({"kind": PixelAnswerKind.DRAG, "src": src, "dst": dst})
1020
609
  if out:
1021
610
  return out
1022
611
 
1023
- # ---- click: {"action": {"action":"click","points":[...]}} ----
1024
612
  action = data.get("action")
1025
- # The model sometimes over-wraps: {"action": {"action": {"action":
1026
- # "click", "points":[...]}}}. Peel nested "action" dicts until we reach
1027
- # the one carrying points/coords (bounded so we never loop forever).
1028
613
  for _ in range(4):
1029
614
  if (
1030
615
  isinstance(action, dict)
@@ -1038,20 +623,13 @@ class ActionPlanner:
1038
623
  points = None
1039
624
  if isinstance(action, dict):
1040
625
  points = action.get("points")
1041
- # single drag emitted under "action"
1042
- if points is None and action.get("action") == "drag":
626
+ if points is None and action.get("action") == ActionKind.DRAG:
1043
627
  src = norm_xy(*(action.get("source") or (None, None)))
1044
628
  dst = norm_xy(*(action.get("target") or (None, None)))
1045
629
  if src and dst:
1046
- return [{"kind": "drag", "src": src, "dst": dst}]
630
+ return [{"kind": PixelAnswerKind.DRAG, "src": src, "dst": dst}]
1047
631
  if points is None:
1048
632
  points = data.get("points")
1049
- # Salvage: some responses carry the click coordinates under "click" (or
1050
- # "coordinates") instead of "points", as [x,y] pairs, {x,y} dicts, or
1051
- # "x, y" strings (sometimes split across elements), with no "points" key
1052
- # at all. Pull every number out of a coordinate-like value and pair them
1053
- # into points so a well-intentioned answer isn't dropped as "unsupported".
1054
- # Skipped when the value is text labels ("dog", "duck") rather than coords.
1055
633
  if not (isinstance(points, list) and points):
1056
634
  for container in (action if isinstance(action, dict) else None, data):
1057
635
  if not isinstance(container, dict):
@@ -1078,5 +656,5 @@ class ActionPlanner:
1078
656
  if xy:
1079
657
  pts.append(xy)
1080
658
  if pts:
1081
- out.append({"kind": "click", "points": pts})
659
+ out.append({"kind": PixelAnswerKind.CLICK, "points": pts})
1082
660
  return out