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.
Files changed (77) hide show
  1. package/AGENTS.md +112 -0
  2. package/NOTICE +36 -0
  3. package/dist/cli-invocation.d.ts +9 -40
  4. package/dist/cli-invocation.js +16 -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 +112 -539
  28. package/dist/solver.js +1088 -2950
  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 -483
  34. package/dist/watcher.d.ts +5 -78
  35. package/dist/watcher.js +0 -56
  36. package/package.json +12 -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 -879
  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 +69 -434
  56. package/python/src/captchakraken/kinds.py +152 -0
  57. package/python/src/captchakraken/models.json +79 -21
  58. package/python/src/captchakraken/overlay.py +65 -498
  59. package/python/src/captchakraken/page_solver.py +1123 -2564
  60. package/python/src/captchakraken/planner.py +70 -428
  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 finetune repo's
33
- # scripts/train_unified.sh), 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
- # geetest_v3_slide 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 haproxy 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>`; haproxy 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,157 +128,79 @@ 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
139
 
271
140
 
272
- def video_action_prompt(n_keyframes: int) -> str:
273
- """The prompt for a challenge served as `n_keyframes` stills.
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).
144
+ RESAMPLE_TEMPERATURES = (0.0,)
145
+
274
146
 
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 finetune 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)
147
+ def sampling_for_level(level: int) -> Dict[str, Any]:
148
+ temps = RESAMPLE_TEMPERATURES or (0.0,)
149
+ temp = temps[min(max(level, 0), len(temps) - 1)]
150
+ if not temp:
151
+ return {}
152
+ return {"temperature": temp, "seed": 1000 + level}
153
+
154
+
155
+ def _sampling_from_env() -> Dict[str, Any]:
156
+ raw = os.environ.get("CAPTCHA_RESAMPLE_LEVEL", "").strip()
157
+ if not raw:
158
+ return {}
159
+ try:
160
+ return sampling_for_level(int(raw))
161
+ except ValueError:
162
+ return {}
284
163
 
285
164
 
286
165
  class ActionPlanner:
287
- """Thin client for the vLLM `captcha` LoRA."""
288
166
 
289
167
  def __init__(
290
168
  self,
291
169
  model: Optional[str] = None,
292
- debug_callback: Optional[Any] = None,
293
170
  base_url: Optional[str] = None,
294
171
  api_key: Optional[str] = None,
295
- expert: Optional[str] = None,
172
+ expert: Optional[PromptFamily] = None,
296
173
  **_: Any,
297
174
  ):
298
- self.debug_callback = debug_callback
299
175
  self.token_usage: List[Dict[str, Any]] = []
300
176
 
301
- # All model/endpoint defaults come from `config` (env-overridable), so
302
- # the planner itself is model-agnostic — swap models via env, not code.
303
177
  self.model = model or config.lora_name()
304
178
  self.base_url = base_url or config.base_url()
305
179
  self.api_key = api_key or config.api_key()
306
- # Prompts follow the MODEL, not this client's release. Resolve by the
307
- # served name first (a hosted endpoint serves `captcha`, not a repo id,
308
- # and models.json maps the alias back), falling back to the adapter repo
309
- # id for a self-hosted deploy that serves under its own name.
310
- #
311
- # Doing this ONCE per planner rather than per request: resolution can
312
- # touch the Hub, and a per-request lookup would put a network call in
313
- # front of every round of every solve.
180
+ self.sampling: Dict[str, Any] = {}
314
181
  _prompt_key = (self.model if prompts.canonical_model_id(self.model)
315
182
  else config.lora_adapter())
316
183
  self.prompts = prompts.resolve(_prompt_key)
317
- # Resolution follows the MODEL for the same reason prompts do, and off
318
- # the same key: an adapter reads a puzzle at the pixel band it trained
319
- # under. Resolved once per planner — see the note above.
320
184
  self.pixel_budget = prompts.pixel_budget(_prompt_key)
321
- # WHICH ADAPTER answers each prompt family. Empty for every model that
322
- # is not routed, which is every model published so far — `_model_for`
323
- # then returns `self.model` for every request and the wire is unchanged.
324
- #
325
- # Resolved once, like the two above, and for a stronger reason: the pin
326
- # is validated here, so `--expert grid` against a single-adapter model
327
- # fails when the solver is BUILT rather than on the first grid it meets.
328
185
  self.expert = (expert if expert is not None
329
186
  else prompts.expert_pin())
330
187
  self.experts = prompts.experts(_prompt_key)
331
188
  if self.expert:
332
- # Raises on an unknown family, and on a pin against an unrouted
333
- # model. Both are configuration errors, and a benchmark that
334
- # quietly measured the generalist while reporting an expert is the
335
- # exact class of silent mispairing models.json exists to prevent.
336
189
  prompts.route(_prompt_key, None, pin=self.expert)
337
- # Auto-start a local vLLM server on the first request if one isn't up
338
- # (no-op for a healthy or remote endpoint). Guarded so we only try once.
339
190
  self._server_ensured = False
340
- # ONE connection, reused for every round of every solve this planner
341
- # serves. `requests.post` opens a fresh TCP connection each call — and
342
- # to a hosted endpoint a TLS handshake with it. Measured against the
343
- # gate's own endpoint: 258ms per request connectionless vs 144ms
344
- # pooled, so every inference was paying ~110ms to re-dial a server it
345
- # had just finished talking to. A multi-round grid pays it per round.
346
- #
347
- # The planner is per-PageSolver and a PageSolver is reusable across
348
- # solves, so a caller who keeps one keeps the connection warm too.
349
191
  self._http = requests.Session()
350
- # Latched off by the first 404 — see `report_outcome`.
351
192
  self._outcome_supported = True
352
193
 
353
- def _model_for(self, family: Optional[str]) -> str:
354
- """The `model` string for a request in `family`.
355
-
356
- `self.model` whenever the model is not routed, so a caller may pass a
357
- family unconditionally. A routed model with no expert for this family
358
- degrades to the generalist — never to another expert, never to an
359
- error (docs/MOE_LORA_DESIGN.md §11).
360
- """
194
+ def _model_for(self, family: Optional[PromptFamily]) -> str:
361
195
  if not self.experts:
362
196
  return self.model
363
197
  return prompts.route(self.model, family, pin=self.expert) or self.model
364
198
 
365
- # ── the solve's own verdict, sent back ─────────────────────────────────
366
- #
367
- # THE ONE THING THE SERVER CANNOT SEE. A wrong answer reaches the gateway as
368
- # a 200 with well-formed JSON in it; only this driver watched the widget.
369
- # Without this report the hosted API cannot tell a solved captcha from a
370
- # failed one, and the failures — the exact boards the model is worst at, on
371
- # real sites — are the one dataset that cannot be collected any other way.
372
- #
373
- # BEST-EFFORT, ALWAYS. It runs after the solve is over and its result is
374
- # already decided, so nothing it does can change the answer the caller gets.
375
- # Every failure is swallowed: a self-hosted vLLM has no such route, a
376
- # network blip is not the caller's problem, and an exception here would turn
377
- # a successful solve into a raised one.
378
- #
379
- # ONE 404 IS ENOUGH. A self-hosted endpoint answers 404 forever, so the
380
- # first one latches this off for the life of the planner rather than paying
381
- # a round trip per solve to be told the same thing again.
382
199
  _OUTCOME_PATH = "/solve-outcome"
383
200
  _OUTCOME_TIMEOUT_S = 3.0
384
201
 
385
202
  def report_outcome(self, session_id: Optional[str], solved: bool) -> bool:
386
- """Tell the API whether the widget accepted. True if it was delivered.
387
-
388
- Never raises. The return value is for tests and for `--debug`; no caller
389
- should branch on it.
390
- """
203
+ """A 404 disables it for the planner's lifetime: a self-hosted vLLM has no /solve-outcome."""
391
204
  if not session_id or not self._outcome_supported:
392
205
  return False
393
206
  if os.getenv(_REPORT_OUTCOME_ENV, "1") == "0":
@@ -403,11 +216,10 @@ class ActionPlanner:
403
216
  json={"session": session_id, "solved": bool(solved)},
404
217
  timeout=self._OUTCOME_TIMEOUT_S,
405
218
  )
406
- except Exception as exc: # noqa: BLE001 — see the note above
219
+ except Exception as exc:
407
220
  self._log(f"outcome report failed: {exc}")
408
221
  return False
409
222
  if resp.status_code == 404:
410
- # Not an error: this endpoint does not serve the route. Stop asking.
411
223
  self._outcome_supported = False
412
224
  self._log("outcome reporting: endpoint has no /solve-outcome; disabled")
413
225
  return False
@@ -418,28 +230,17 @@ class ActionPlanner:
418
230
  def _log(self, message: str) -> None:
419
231
  if DEBUG:
420
232
  print(f"[Planner] {message}", file=sys.stderr)
421
- if self.debug_callback:
422
- self.debug_callback(f"[Planner] {message}")
423
233
 
424
234
  def _chat_with_image(self, prompt: str, image_path: str, max_tokens: int = 512,
425
- family: Optional[str] = None) -> str:
235
+ family: Optional[PromptFamily] = None) -> str:
426
236
  return self._chat_with_images(prompt, [image_path], max_tokens=max_tokens,
427
237
  family=family)
428
238
 
429
239
  def _chat_with_images(
430
240
  self, prompt: str, image_paths: List[str], max_tokens: int = 512,
431
- family: Optional[str] = None,
241
+ family: Optional[PromptFamily] = None,
432
242
  ) -> str:
433
- """One request carrying N images followed by the prompt.
434
-
435
- The images go in ONE message, before the text, in the order given. That
436
- ordering is what makes an animated challenge answerable: the model is told
437
- "you are given N keyframes, in order: frame 1, frame 2, …" and matches those
438
- names to the images positionally. Splitting them across requests, or putting
439
- the text first, would break the correspondence between the numbers in the
440
- prompt and the pictures — and the frame number is the part the page driver
441
- acts on.
442
- """
243
+ """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."""
443
244
  if not image_paths:
444
245
  raise ValueError("no images to send")
445
246
 
@@ -466,29 +267,24 @@ class ActionPlanner:
466
267
  "messages": messages,
467
268
  "temperature": 0,
468
269
  "max_tokens": max_tokens,
469
- # Qwen3.5's reasoning otherwise eats the token budget. `/no_think`
470
- # in the prompt alone is unreliable; disabling at the chat-template
471
- # level is the documented way.
270
+ # Both spellings: Ollama ignores chat_template_kwargs and defaults thinking on; vLLM reads reasoning_effort only when the kwargs are unset.
472
271
  "chat_template_kwargs": {"enable_thinking": False},
272
+ "reasoning_effort": "none",
473
273
  }
274
+ sampling = self.sampling or _sampling_from_env()
275
+ if sampling:
276
+ payload.update(sampling)
474
277
 
475
278
  headers = {
476
279
  "Authorization": f"Bearer {self.api_key}",
477
280
  "Content-Type": "application/json",
478
- # Fleet routing: absent unless CAPTCHA_REQUEST_PRIORITY is set (see
479
- # routing_headers). Low-priority batch traffic is steered to the
480
- # backup GPUs by the haproxy front on this header.
481
281
  **routing_headers(),
482
282
  }
483
283
 
484
- # Hands-off server: before the very first request, make sure something
485
- # is listening (auto-start a local vLLM if needed; no-op if it's already
486
- # up or the endpoint is remote). Best-effort — a failure here surfaces as
487
- # a normal connection error on the request below.
488
284
  if not self._server_ensured:
489
285
  try:
490
286
  ensure_server(self.base_url)
491
- except Exception as e: # noqa: BLE001 — don't mask the real request error
287
+ except Exception as e:
492
288
  self._log(f"ensure_server: {e}")
493
289
  self._server_ensured = True
494
290
 
@@ -496,19 +292,8 @@ class ActionPlanner:
496
292
  self._log(f"POST {url} model={model} max_tokens={max_tokens} "
497
293
  f"images={len(parts)}")
498
294
 
499
- with timed("planner.chat"):
500
- resp = self._http.post(url, headers=headers, json=payload, timeout=120)
501
-
502
- # Surface auth / billing / server errors as something the reader can act
503
- # on, instead of letting resp.json() blow up with a cryptic "Expecting
504
- # value: line 1 column 1" on a non-JSON response.
505
- #
506
- # `errors.from_response` decides which of the two worlds we are in by
507
- # looking for the gateway's `error.code`: present means the hosted API
508
- # refused us and there is a real explanation to give (out of credits,
509
- # rate limited, attempt abandoned); absent means a local vLLM or a proxy,
510
- # and the old message — including the bearer-token hint on 401/403 — is
511
- # reproduced unchanged.
295
+ resp = self._http.post(url, headers=headers, json=payload, timeout=120)
296
+
512
297
  if not resp.ok:
513
298
  raise errors.from_response(resp, url)
514
299
 
@@ -547,17 +332,9 @@ class ActionPlanner:
547
332
  else:
548
333
  return None
549
334
 
550
- # strict=False tolerates unescaped control chars INSIDE strings — the
551
- # model routinely emits coordinates as pretty-printed strings with a
552
- # literal newline, e.g. "click": ["277,\n 728", ...], which strict JSON
553
- # rejects. Without this the whole response fails to parse and a solvable
554
- # click puzzle is dropped as "unsupported".
555
335
  try:
556
336
  return json.loads(text[start:end], strict=False)
557
337
  except json.JSONDecodeError:
558
- # The model sometimes truncates (hit max_tokens) or over-nests its
559
- # pretty-printed JSON, leaving brackets unclosed. Repair by balancing
560
- # from the first opener to the end of the raw text.
561
338
  repaired = ActionPlanner._balance_json(text[start:])
562
339
  if repaired is not None:
563
340
  try:
@@ -568,9 +345,6 @@ class ActionPlanner:
568
345
 
569
346
  @staticmethod
570
347
  def _balance_json(text: str) -> Optional[str]:
571
- """Close any brackets/braces the model left open (string-aware), so a
572
- truncated ``{"action": {"points": [[1,2],`` still parses. Returns None if
573
- there was nothing to balance."""
574
348
  stack: List[str] = []
575
349
  in_str = False
576
350
  esc = False
@@ -598,7 +372,6 @@ class ActionPlanner:
598
372
  out = text
599
373
  if in_str:
600
374
  out += '"'
601
- # Drop a dangling comma / partial token before the closers.
602
375
  out = out.rstrip().rstrip(",")
603
376
  for opener in reversed(stack):
604
377
  out += "}" if opener == "{" else "]"
@@ -609,16 +382,8 @@ class ActionPlanner:
609
382
  image_path: str,
610
383
  rows: int,
611
384
  cols: int,
612
- retry_mode: Optional[str] = None,
385
+ retry_mode: Optional[RetryMode] = None,
613
386
  ) -> List[int]:
614
- """Return the list of 1-indexed cells the model wants to click.
615
-
616
- retry_mode == "missed-tiles": the previous submission was rejected
617
- by the captcha vendor with an under-selection error. Append an
618
- explicit recovery instruction that tells the model the FULL grid
619
- contains at least one matching tile it didn't pick last time. This
620
- nudges it off the "I already covered everything" attractor.
621
- """
622
387
  total = rows * cols
623
388
  if rows == 4 and cols == 4:
624
389
  grid_hint = "Hint: Single large image split into tiles. Select ALL parts."
@@ -628,7 +393,7 @@ class ActionPlanner:
628
393
  prompt = self.prompts.grid_prompt(
629
394
  rows=rows, cols=cols, grid_hint=grid_hint
630
395
  )
631
- if retry_mode == "missed-tiles":
396
+ if retry_mode == RetryMode.MISSED_TILES:
632
397
  prompt = (
633
398
  prompt
634
399
  + "\n\nIMPORTANT: A previous submission was rejected because not all "
@@ -638,29 +403,16 @@ class ActionPlanner:
638
403
  "including any matches you may have overlooked."
639
404
  )
640
405
  raw = self._chat_with_image(prompt, image_path, max_tokens=128,
641
- family="grid")
406
+ family=PromptFamily.GRID)
642
407
  out = self._normalize_grid(self._parse_json(raw), total)
643
408
  self._log(f"grid selection -> {out}")
644
409
  return out
645
410
 
646
411
  @staticmethod
647
412
  def _normalize_grid(data: Any, total: int) -> List[int]:
648
- """Map the model's grid JSON to a list of 1-indexed cell numbers.
649
-
650
- Accepts the trained shape (a bare JSON array) plus the wrapped forms the
651
- model drifts into: {"target_ids": [...]} and {"action": {"target_ids":
652
- [...]}}. Out-of-range and non-numeric entries are dropped rather than
653
- raising — a single junk element must not cost the whole selection.
654
-
655
- Split out of get_grid_selection so the parse is testable without a
656
- model, mirroring _normalize_pixel.
657
- """
658
413
  if isinstance(data, list):
659
414
  ids = data
660
415
  elif isinstance(data, dict):
661
- # `action` is a dict on the grid path but a bare string ("drag") on
662
- # the pixel path — guard so a mis-routed response returns [] rather
663
- # than raising AttributeError.
664
416
  nested = data.get("action")
665
417
  nested_ids = nested.get("target_ids") if isinstance(nested, dict) else None
666
418
  ids = data.get("target_ids") or nested_ids or []
@@ -678,56 +430,20 @@ class ActionPlanner:
678
430
  return out
679
431
 
680
432
  def get_pixel_actions(self, image_path: str, text_mode: bool = False) -> List[Dict[str, Any]]:
681
- """Solve a non-grid click/drag/slide/text puzzle.
682
-
683
- Sends the trained action prompt + image, parses the model's JSON, and
684
- returns a list of normalized actions with all coordinates on a 0–1
685
- scale:
686
-
687
- {"kind": "click", "points": [(x, y), ...]}
688
- {"kind": "drag", "src": (x, y), "dst": (x, y)}
689
- {"kind": "slide", "dst": (x, y)} — puzzle-piece slider
690
- {"kind": "type", "text": "<the code>"} — distorted text
691
-
692
- `text_mode` swaps in the distorted-text prompt. It is chosen by the
693
- DRIVER, from the widget's DOM (a visible text box in the challenge),
694
- because nothing about the picture reliably says "this one is typed" —
695
- BotDetect's warped letters and hCaptcha's "click the matching letter"
696
- look alike to a pixel classifier, and the two want opposite answers.
697
-
698
- Returns [] if the model produced nothing usable. The solver turns these
699
- into ClickAction / DragAction / TypeAction.
700
- """
701
433
  prompt = self.prompts.text_prompt() if text_mode else self.prompts.action_prompt
702
434
  raw = self._chat_with_image(prompt, image_path, max_tokens=512,
703
- family="text" if text_mode else "pixel")
435
+ family=PromptFamily.TEXT if text_mode else PromptFamily.PIXEL)
704
436
  data = self._parse_json(raw)
705
437
  actions = self._normalize_pixel(data)
706
438
  self._log(f"pixel actions -> {actions}")
707
439
  return actions
708
440
 
709
441
  def get_keyframe_actions(self, keyframe_paths: List[str]) -> List[Dict[str, Any]]:
710
- """Solve an ANIMATED challenge from its keyframes.
711
-
712
- Same normalized actions as `get_pixel_actions`, each additionally carrying
713
- `"frame"`: the 1-based keyframe the model chose to act on, or None if it did
714
- not name a usable one.
715
-
716
- The frame is not decoration. On these puzzles the target is only there part
717
- of the time, so the coordinates are only correct while the widget looks like
718
- the frame they were read off. The page driver holds the mouse until it does.
719
- An action with `frame=None` is still returned — the caller decides whether to
720
- click blind or give up, and dropping it here would look identical to "the
721
- model had nothing to say".
722
- """
723
442
  if not keyframe_paths:
724
443
  return []
725
- # Per model, not per client: generation 1 has no animated prompt at all,
726
- # so this raises a clear error there instead of sending a v1 model a
727
- # keyframe request it was never trained to read.
728
444
  prompt = self.prompts.video_prompt(len(keyframe_paths))
729
445
  raw = self._chat_with_images(prompt, list(keyframe_paths), max_tokens=512,
730
- family="video")
446
+ family=PromptFamily.VIDEO)
731
447
  data = self._parse_json(raw)
732
448
  actions = self._normalize_pixel(data)
733
449
  frame = self._normalize_frame(data, len(keyframe_paths))
@@ -738,14 +454,7 @@ class ActionPlanner:
738
454
 
739
455
  @staticmethod
740
456
  def _normalize_frame(data: Any, n_keyframes: int) -> Optional[int]:
741
- """The keyframe number the model chose, or None.
742
-
743
- Out-of-range is treated as "did not choose" rather than clamped. Clamping a
744
- 7 to 6 would invent an intent the model never had, and the driver would then
745
- wait for a picture the answer was not about — a slow failure that looks like
746
- a flaky page. Tolerates the nested shape the model drifts into
747
- ({"action": {"frame": n}}), the same way the click/drag parsing does.
748
- """
457
+ """Out of range is None, not clamped: clamping a 7 to 6 would invent an intent."""
749
458
  if not isinstance(data, dict):
750
459
  return None
751
460
  raw = data.get("frame")
@@ -759,22 +468,13 @@ class ActionPlanner:
759
468
 
760
469
  @staticmethod
761
470
  def _normalize_pixel(data: Any) -> List[Dict[str, Any]]:
762
- """Map the model's 0–1000 click/drag JSON to 0–1 normalized actions.
763
-
764
- Tolerant of the two trained shapes plus a few near-misses:
765
- click: {"action": {"action": "click", "points": [[x, y], ...]}}
766
- or top-level {"points": [...]} / {"action": {"points": [...]}}
767
- drag: {"output": [{"Action": "simulate_drag",
768
- "SourcePosition": {x, y},
769
- "EstimatedPosition": {x, y}}, ...]}
770
- """
471
+ """Every answer shape the model has been seen to produce, oldest last; `kind` is a PixelAnswerKind."""
771
472
  def norm_xy(x: Any, y: Any) -> Optional[tuple]:
772
473
  try:
773
474
  fx, fy = float(x) / 1000.0, float(y) / 1000.0
774
475
  except (TypeError, ValueError):
775
476
  return None
776
477
  if not (0.0 <= fx <= 1.0 and 0.0 <= fy <= 1.0):
777
- # Some outputs use 0–1 already; accept those too.
778
478
  if 0.0 <= float(x) <= 1.0 and 0.0 <= float(y) <= 1.0:
779
479
  fx, fy = float(x), float(y)
780
480
  else:
@@ -782,10 +482,6 @@ class ActionPlanner:
782
482
  return (fx, fy)
783
483
 
784
484
  def flat_numbers(v: Any) -> List[float]:
785
- """Every number anywhere inside v, in order. Handles nested lists,
786
- {x,y} dicts, bare numbers, and numeric strings like "277, 728" (the
787
- model sometimes emits coordinates as strings, occasionally even split
788
- across separate array elements)."""
789
485
  nums: List[float] = []
790
486
  if isinstance(v, bool):
791
487
  return nums
@@ -803,10 +499,6 @@ class ActionPlanner:
803
499
  return nums
804
500
 
805
501
  def coordish(v: Any) -> bool:
806
- """True when v is a non-empty list of COORDINATES (numbers, [x,y]
807
- pairs, {x,y} dicts, or numeric strings) — not text labels like
808
- "dog". Used to decide whether a "click"/"coordinates" array carries
809
- points we should salvage."""
810
502
  if not isinstance(v, (list, tuple)) or not v:
811
503
  return False
812
504
  for e in v:
@@ -823,29 +515,15 @@ class ActionPlanner:
823
515
  if not isinstance(data, dict):
824
516
  return out
825
517
 
826
- # ---- type (CANONICAL — the schema TEXT_INSTRUCTION asks for on the
827
- # distorted-text captchas: {"action": "type", "text": "<the code>"}).
828
- # First, because it is the one answer family with NO coordinate in it:
829
- # every branch below keys off a number, so a typed answer that fell
830
- # through reached the end and normalized to nothing.
831
- # The code is passed through VERBATIM — case and spacing are part of what
832
- # the model read off the image, and "tidying" them submits a different
833
- # answer than the one it gave.
834
518
  act = data.get("action")
835
- if isinstance(act, dict) and act.get("action") == "type":
836
- act = act.get("action") # unwrap {"action": {"action": "type", ...}}
837
- if act == "type" or (isinstance(data.get("text"), str) and "drags" not in data):
519
+ if isinstance(act, dict) and act.get("action") == ActionKind.TYPE:
520
+ act = act.get("action")
521
+ if act == ActionKind.TYPE or (isinstance(data.get("text"), str) and "drags" not in data):
838
522
  container = data if isinstance(data.get("text"), str) else data.get("action")
839
523
  text = container.get("text") if isinstance(container, dict) else None
840
524
  if isinstance(text, str) and text:
841
- return [{"kind": "type", "text": text}]
842
-
843
- # ---- drag (CANONICAL — the schema PIXEL_ACTION_PROMPT asks for and the
844
- # LoRA is trained on, see instructions.py::ACTION_INSTRUCTION):
845
- # {"action":"drag","drags":[{"source","from":[x,y],"destination","to":[x,y]}]}
846
- # Note "action" here is the STRING "drag", not a dict, so the click path
847
- # below never sees it. The legacy/simulate_drag branches that follow stay
848
- # as fallbacks for older adapters.
525
+ return [{"kind": PixelAnswerKind.TYPE, "text": text}]
526
+
849
527
  content_drags = data.get("drags")
850
528
  if content_drags is None and isinstance(data.get("action"), dict):
851
529
  content_drags = data["action"].get("drags")
@@ -858,27 +536,19 @@ class ActionPlanner:
858
536
  snums = flat_numbers(d.get("from"))
859
537
  dnums = flat_numbers(d.get("to"))
860
538
  if len(dnums) < 2:
861
- continue # a piece with nowhere to go is not actionable
539
+ continue
862
540
  dst = norm_xy(dnums[0], dnums[1])
863
541
  if not dst:
864
542
  continue
865
543
  if len(snums) >= 2:
866
544
  src = norm_xy(snums[0], snums[1])
867
545
  if src:
868
- out.append({"kind": "drag", "src": src, "dst": dst})
546
+ out.append({"kind": PixelAnswerKind.DRAG, "src": src, "dst": dst})
869
547
  else:
870
- # SOURCELESS — a puzzle-piece slider. The prompt's "FOR
871
- # PUZZLE PIECE SLIDER PUZZLES" clause tells the model to
872
- # leave the source empty precisely because the piece is not
873
- # what you pick up: the handle is, somewhere else entirely,
874
- # and how far it has to travel is not knowable from the
875
- # picture. Requiring both ends here dropped the one answer
876
- # shape the prompt asks for on every slide puzzle.
877
- out.append({"kind": "slide", "dst": dst})
548
+ out.append({"kind": PixelAnswerKind.SLIDE, "dst": dst})
878
549
  if out:
879
550
  return out
880
551
 
881
- # ---- drag: {"output": [ {simulate_drag ...}, ... ]} ----
882
552
  drags = data.get("output")
883
553
  if isinstance(drags, list) and drags:
884
554
  for d in drags:
@@ -889,34 +559,17 @@ class ActionPlanner:
889
559
  src = norm_xy(sp.get("x"), sp.get("y")) if isinstance(sp, dict) else None
890
560
  dst = norm_xy(ep.get("x"), ep.get("y")) if isinstance(ep, dict) else None
891
561
  if src and dst:
892
- out.append({"kind": "drag", "src": src, "dst": dst})
562
+ out.append({"kind": PixelAnswerKind.DRAG, "src": src, "dst": dst})
893
563
  if out:
894
564
  return out
895
565
 
896
- # ---- drag: {"action": {"simulate_drag": [{source_position,
897
- # destination_position}]}} ----
898
- # The full-puzzle LoRA actually emits drags in this snake_case shape
899
- # (not the prompt's {"output":[{"Action":"simulate_drag",...}]}), and
900
- # packs each coordinate as {"x": [x, y]} — flat_numbers() pulls the pair
901
- # out of that. Without this branch a correctly-solved drag (e.g. hCaptcha
902
- # "drag ONE character to the matching character") is dropped as
903
- # "unsupported".
904
566
  sd = data.get("simulate_drag")
905
567
  if sd is None and isinstance(action := data.get("action"), dict):
906
568
  sd = action.get("simulate_drag")
907
- # The model emits simulate_drag as EITHER a list of drags OR a single
908
- # drag object; normalize the single-object form to a one-element list so
909
- # both parse (the object form was silently dropped as "unsupported").
910
569
  if isinstance(sd, dict):
911
570
  sd = [sd]
912
571
  if isinstance(sd, list) and sd:
913
572
  def _drag_coords(obj: Any, *roles: str) -> List[float]:
914
- # Pull the source/destination coordinate pair regardless of key
915
- # casing or separators — the LoRA freely varies between
916
- # source_position / sourcePosition / SourcePosition and
917
- # destination_position / destinationPosition / EstimatedPosition.
918
- # The coord value may be {"x": [x, y]}, [x, y], {x, y}, or a
919
- # string; flat_numbers() handles all of those.
920
573
  if not isinstance(obj, dict):
921
574
  return []
922
575
  for k, v in obj.items():
@@ -937,15 +590,11 @@ class ActionPlanner:
937
590
  src = norm_xy(snums[0], snums[1])
938
591
  dst = norm_xy(dnums[0], dnums[1])
939
592
  if src and dst:
940
- out.append({"kind": "drag", "src": src, "dst": dst})
593
+ out.append({"kind": PixelAnswerKind.DRAG, "src": src, "dst": dst})
941
594
  if out:
942
595
  return out
943
596
 
944
- # ---- click: {"action": {"action":"click","points":[...]}} ----
945
597
  action = data.get("action")
946
- # The model sometimes over-wraps: {"action": {"action": {"action":
947
- # "click", "points":[...]}}}. Peel nested "action" dicts until we reach
948
- # the one carrying points/coords (bounded so we never loop forever).
949
598
  for _ in range(4):
950
599
  if (
951
600
  isinstance(action, dict)
@@ -959,20 +608,13 @@ class ActionPlanner:
959
608
  points = None
960
609
  if isinstance(action, dict):
961
610
  points = action.get("points")
962
- # single drag emitted under "action"
963
- if points is None and action.get("action") == "drag":
611
+ if points is None and action.get("action") == ActionKind.DRAG:
964
612
  src = norm_xy(*(action.get("source") or (None, None)))
965
613
  dst = norm_xy(*(action.get("target") or (None, None)))
966
614
  if src and dst:
967
- return [{"kind": "drag", "src": src, "dst": dst}]
615
+ return [{"kind": PixelAnswerKind.DRAG, "src": src, "dst": dst}]
968
616
  if points is None:
969
617
  points = data.get("points")
970
- # Salvage: some responses carry the click coordinates under "click" (or
971
- # "coordinates") instead of "points", as [x,y] pairs, {x,y} dicts, or
972
- # "x, y" strings (sometimes split across elements), with no "points" key
973
- # at all. Pull every number out of a coordinate-like value and pair them
974
- # into points so a well-intentioned answer isn't dropped as "unsupported".
975
- # Skipped when the value is text labels ("dog", "duck") rather than coords.
976
618
  if not (isinstance(points, list) and points):
977
619
  for container in (action if isinstance(action, dict) else None, data):
978
620
  if not isinstance(container, dict):
@@ -999,5 +641,5 @@ class ActionPlanner:
999
641
  if xy:
1000
642
  pts.append(xy)
1001
643
  if pts:
1002
- out.append({"kind": "click", "points": pts})
644
+ out.append({"kind": PixelAnswerKind.CLICK, "points": pts})
1003
645
  return out