captchakraken 2.6.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -16
- package/dist/cli-invocation.d.ts +2 -0
- package/dist/cli-invocation.js +5 -1
- package/dist/errors.d.ts +1 -1
- package/dist/humanize.d.ts +377 -0
- package/dist/humanize.js +716 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +44 -1
- package/dist/playwright-types.d.ts +18 -0
- package/dist/solver.d.ts +115 -5
- package/dist/solver.js +583 -190
- package/dist/timing.d.ts +48 -0
- package/dist/timing.js +103 -0
- package/dist/trajectory.d.ts +27 -0
- package/dist/trajectory.js +104 -0
- package/dist/types.d.ts +129 -0
- package/package.json +11 -3
- package/python/README.md +76 -5
- package/python/pyproject.toml +21 -4
- package/python/src/captchakraken/__init__.py +36 -1
- package/python/src/captchakraken/cli.py +62 -4
- package/python/src/captchakraken/config.py +28 -2
- package/python/src/captchakraken/errors.py +24 -0
- package/python/src/captchakraken/humanize.py +929 -0
- package/python/src/captchakraken/keyframes.py +99 -0
- package/python/src/captchakraken/models.json +149 -13
- package/python/src/captchakraken/page_solver.py +588 -291
- package/python/src/captchakraken/pinned_model.json +3 -2
- package/python/src/captchakraken/planner.py +118 -9
- package/python/src/captchakraken/prompts.py +182 -0
- package/python/src/captchakraken/server_manager.py +9 -4
- package/python/src/captchakraken/solver.py +97 -57
- package/python/src/captchakraken/timing.py +8 -3
- package/python/src/captchakraken/tool_calls/find_grid.py +357 -20
- package/python/src/captchakraken/trajectory.py +117 -0
- package/python/src/captchakraken/updater.py +62 -1
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
"serving prompt fails CI until someone consciously re-pins \u2014 which forces",
|
|
13
13
|
"the question 'does the pinned adapter still expect this prompt?'.",
|
|
14
14
|
"",
|
|
15
|
-
"To ship a new adapter: update lora_adapter/lora_revision, re-run the
|
|
16
|
-
"static-image gate against it, and update prompt_sha256 if the
|
|
15
|
+
"To ship a new adapter: update lora_adapter/lora_revision, re-run the",
|
|
16
|
+
"static-image accuracy gate against it, and update prompt_sha256 if the",
|
|
17
|
+
"prompts moved."
|
|
17
18
|
],
|
|
18
19
|
"base_model": "RedHatAI/Qwen3.5-9B-FP8-dynamic",
|
|
19
20
|
"lora_adapter": "CaptchaKraken/CaptchaKraken-Lora-v1.2",
|
|
@@ -124,6 +124,14 @@ _CLIENT_ENV = "CAPTCHA_KRAKEN_CLIENT"
|
|
|
124
124
|
_SESSION_HEADER = "X-CK-Session"
|
|
125
125
|
_SESSION_ENV = "CAPTCHA_KRAKEN_SESSION"
|
|
126
126
|
|
|
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.
|
|
133
|
+
_REPORT_OUTCOME_ENV = "CAPTCHA_REPORT_OUTCOME"
|
|
134
|
+
|
|
127
135
|
# Arbitrary extra headers, as "Name: value" pairs separated by newlines or
|
|
128
136
|
# commas. Empty and absent by default.
|
|
129
137
|
#
|
|
@@ -284,6 +292,7 @@ class ActionPlanner:
|
|
|
284
292
|
debug_callback: Optional[Any] = None,
|
|
285
293
|
base_url: Optional[str] = None,
|
|
286
294
|
api_key: Optional[str] = None,
|
|
295
|
+
expert: Optional[str] = None,
|
|
287
296
|
**_: Any,
|
|
288
297
|
):
|
|
289
298
|
self.debug_callback = debug_callback
|
|
@@ -309,9 +318,102 @@ class ActionPlanner:
|
|
|
309
318
|
# the same key: an adapter reads a puzzle at the pixel band it trained
|
|
310
319
|
# under. Resolved once per planner — see the note above.
|
|
311
320
|
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
|
+
self.expert = (expert if expert is not None
|
|
329
|
+
else prompts.expert_pin())
|
|
330
|
+
self.experts = prompts.experts(_prompt_key)
|
|
331
|
+
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
|
+
prompts.route(_prompt_key, None, pin=self.expert)
|
|
312
337
|
# Auto-start a local vLLM server on the first request if one isn't up
|
|
313
338
|
# (no-op for a healthy or remote endpoint). Guarded so we only try once.
|
|
314
339
|
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
|
+
self._http = requests.Session()
|
|
350
|
+
# Latched off by the first 404 — see `report_outcome`.
|
|
351
|
+
self._outcome_supported = True
|
|
352
|
+
|
|
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
|
+
"""
|
|
361
|
+
if not self.experts:
|
|
362
|
+
return self.model
|
|
363
|
+
return prompts.route(self.model, family, pin=self.expert) or self.model
|
|
364
|
+
|
|
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
|
+
_OUTCOME_PATH = "/solve-outcome"
|
|
383
|
+
_OUTCOME_TIMEOUT_S = 3.0
|
|
384
|
+
|
|
385
|
+
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
|
+
"""
|
|
391
|
+
if not session_id or not self._outcome_supported:
|
|
392
|
+
return False
|
|
393
|
+
if os.getenv(_REPORT_OUTCOME_ENV, "1") == "0":
|
|
394
|
+
return False
|
|
395
|
+
url = f"{self.base_url}{self._OUTCOME_PATH}"
|
|
396
|
+
try:
|
|
397
|
+
resp = self._http.post(
|
|
398
|
+
url,
|
|
399
|
+
headers={
|
|
400
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
401
|
+
"Content-Type": "application/json",
|
|
402
|
+
},
|
|
403
|
+
json={"session": session_id, "solved": bool(solved)},
|
|
404
|
+
timeout=self._OUTCOME_TIMEOUT_S,
|
|
405
|
+
)
|
|
406
|
+
except Exception as exc: # noqa: BLE001 — see the note above
|
|
407
|
+
self._log(f"outcome report failed: {exc}")
|
|
408
|
+
return False
|
|
409
|
+
if resp.status_code == 404:
|
|
410
|
+
# Not an error: this endpoint does not serve the route. Stop asking.
|
|
411
|
+
self._outcome_supported = False
|
|
412
|
+
self._log("outcome reporting: endpoint has no /solve-outcome; disabled")
|
|
413
|
+
return False
|
|
414
|
+
ok = 200 <= resp.status_code < 300
|
|
415
|
+
self._log(f"outcome report {session_id} solved={solved} -> {resp.status_code}")
|
|
416
|
+
return ok
|
|
315
417
|
|
|
316
418
|
def _log(self, message: str) -> None:
|
|
317
419
|
if DEBUG:
|
|
@@ -319,11 +421,14 @@ class ActionPlanner:
|
|
|
319
421
|
if self.debug_callback:
|
|
320
422
|
self.debug_callback(f"[Planner] {message}")
|
|
321
423
|
|
|
322
|
-
def _chat_with_image(self, prompt: str, image_path: str, max_tokens: int = 512
|
|
323
|
-
|
|
424
|
+
def _chat_with_image(self, prompt: str, image_path: str, max_tokens: int = 512,
|
|
425
|
+
family: Optional[str] = None) -> str:
|
|
426
|
+
return self._chat_with_images(prompt, [image_path], max_tokens=max_tokens,
|
|
427
|
+
family=family)
|
|
324
428
|
|
|
325
429
|
def _chat_with_images(
|
|
326
|
-
self, prompt: str, image_paths: List[str], max_tokens: int = 512
|
|
430
|
+
self, prompt: str, image_paths: List[str], max_tokens: int = 512,
|
|
431
|
+
family: Optional[str] = None,
|
|
327
432
|
) -> str:
|
|
328
433
|
"""One request carrying N images followed by the prompt.
|
|
329
434
|
|
|
@@ -355,8 +460,9 @@ class ActionPlanner:
|
|
|
355
460
|
},
|
|
356
461
|
]
|
|
357
462
|
|
|
463
|
+
model = self._model_for(family)
|
|
358
464
|
payload = {
|
|
359
|
-
"model":
|
|
465
|
+
"model": model,
|
|
360
466
|
"messages": messages,
|
|
361
467
|
"temperature": 0,
|
|
362
468
|
"max_tokens": max_tokens,
|
|
@@ -387,11 +493,11 @@ class ActionPlanner:
|
|
|
387
493
|
self._server_ensured = True
|
|
388
494
|
|
|
389
495
|
url = f"{self.base_url}/chat/completions"
|
|
390
|
-
self._log(f"POST {url} model={
|
|
496
|
+
self._log(f"POST {url} model={model} max_tokens={max_tokens} "
|
|
391
497
|
f"images={len(parts)}")
|
|
392
498
|
|
|
393
499
|
with timed("planner.chat"):
|
|
394
|
-
resp =
|
|
500
|
+
resp = self._http.post(url, headers=headers, json=payload, timeout=120)
|
|
395
501
|
|
|
396
502
|
# Surface auth / billing / server errors as something the reader can act
|
|
397
503
|
# on, instead of letting resp.json() blow up with a cryptic "Expecting
|
|
@@ -531,7 +637,8 @@ class ActionPlanner:
|
|
|
531
637
|
"Return the complete list of cell numbers that match the description, "
|
|
532
638
|
"including any matches you may have overlooked."
|
|
533
639
|
)
|
|
534
|
-
raw = self._chat_with_image(prompt, image_path, max_tokens=128
|
|
640
|
+
raw = self._chat_with_image(prompt, image_path, max_tokens=128,
|
|
641
|
+
family="grid")
|
|
535
642
|
out = self._normalize_grid(self._parse_json(raw), total)
|
|
536
643
|
self._log(f"grid selection -> {out}")
|
|
537
644
|
return out
|
|
@@ -592,7 +699,8 @@ class ActionPlanner:
|
|
|
592
699
|
into ClickAction / DragAction / TypeAction.
|
|
593
700
|
"""
|
|
594
701
|
prompt = self.prompts.text_prompt() if text_mode else self.prompts.action_prompt
|
|
595
|
-
raw = self._chat_with_image(prompt, image_path, max_tokens=512
|
|
702
|
+
raw = self._chat_with_image(prompt, image_path, max_tokens=512,
|
|
703
|
+
family="text" if text_mode else "pixel")
|
|
596
704
|
data = self._parse_json(raw)
|
|
597
705
|
actions = self._normalize_pixel(data)
|
|
598
706
|
self._log(f"pixel actions -> {actions}")
|
|
@@ -618,7 +726,8 @@ class ActionPlanner:
|
|
|
618
726
|
# so this raises a clear error there instead of sending a v1 model a
|
|
619
727
|
# keyframe request it was never trained to read.
|
|
620
728
|
prompt = self.prompts.video_prompt(len(keyframe_paths))
|
|
621
|
-
raw = self._chat_with_images(prompt, list(keyframe_paths), max_tokens=512
|
|
729
|
+
raw = self._chat_with_images(prompt, list(keyframe_paths), max_tokens=512,
|
|
730
|
+
family="video")
|
|
622
731
|
data = self._parse_json(raw)
|
|
623
732
|
actions = self._normalize_pixel(data)
|
|
624
733
|
frame = self._normalize_frame(data, len(keyframe_paths))
|
|
@@ -247,6 +247,80 @@ def canonical_model_id(model: Optional[str]) -> Optional[str]:
|
|
|
247
247
|
return alias if isinstance(alias, str) else None
|
|
248
248
|
|
|
249
249
|
|
|
250
|
+
# ── availability: can these weights be obtained at all? ─────────────────────
|
|
251
|
+
#
|
|
252
|
+
# Same registry, same reason as prompt_version and pixel_budget: it is a fact
|
|
253
|
+
# about the MODEL, and the one place that already decides what an unpinned
|
|
254
|
+
# client downloads. See models.json's header comment.
|
|
255
|
+
|
|
256
|
+
PUBLIC = "public"
|
|
257
|
+
PRIVATE = "private"
|
|
258
|
+
LICENSED = "licensed"
|
|
259
|
+
#: Anything outside this set is a typo, and a typo must not read as "public".
|
|
260
|
+
#: The whole class of bug this file exists to prevent is a field that fails
|
|
261
|
+
#: open — `check_prompt_parity.py` rejects an unknown value at release time,
|
|
262
|
+
#: and `is_licensed` below treats one as licensed rather than guessing.
|
|
263
|
+
#:
|
|
264
|
+
#: Spelled out as literals rather than `(PUBLIC, PRIVATE, LICENSED)`: the
|
|
265
|
+
#: release gate reads this file by AST so it can run in a training venv with no
|
|
266
|
+
#: client installed, and `ast.literal_eval` cannot follow a name. A tuple of
|
|
267
|
+
#: names reads as "unset" to the gate, which is the check silently not running.
|
|
268
|
+
#: `tests/test_prompt_registry.py` pins that all four constants agree.
|
|
269
|
+
AVAILABILITIES = ("public", "private", "licensed")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def availability(model: Optional[str]) -> str:
|
|
273
|
+
"""`"public"`, `"private"` or `"licensed"` for `model`. Absent means public.
|
|
274
|
+
|
|
275
|
+
The three answer ONE question — can these weights be obtained, and by whom:
|
|
276
|
+
|
|
277
|
+
public on the Hub, by anyone.
|
|
278
|
+
private on the Hub in a PRIVATE repo. The bytes exist and `fetch`
|
|
279
|
+
pulls them, but only for a token this account has authorised.
|
|
280
|
+
Everyone else gets 401/404 from the Hub, which is the right
|
|
281
|
+
answer and not something to pre-empt with a refusal.
|
|
282
|
+
licensed no Hub repo at all. Hosted API or a licence we issue.
|
|
283
|
+
|
|
284
|
+
`private` is the newest and it is NOT a softer `licensed`. They differ in
|
|
285
|
+
what an authorised holder can do, which is the whole reason the field is
|
|
286
|
+
not a boolean: a licensed model has nothing to download however good your
|
|
287
|
+
credentials are, so refusing early is a KINDNESS — it replaces a
|
|
288
|
+
`RepositoryNotFoundError` that reads as "you are not logged in" with the
|
|
289
|
+
truth. Doing the same to a private model would be a lie, and would make our
|
|
290
|
+
own admin token unable to pull weights it is entitled to.
|
|
291
|
+
|
|
292
|
+
An unregistered model is public too: it is not ours, and refusing to
|
|
293
|
+
download a stranger's adapter because we have never heard of it would break
|
|
294
|
+
every self-hoster who trained their own.
|
|
295
|
+
"""
|
|
296
|
+
entry = registered_models().get(canonical_model_id(model) or "") or {}
|
|
297
|
+
value = entry.get("availability")
|
|
298
|
+
return value if isinstance(value, str) and value else PUBLIC
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def is_licensed(model: Optional[str]) -> bool:
|
|
302
|
+
"""True if these weights are NOT OBTAINABLE AT ALL — hosted API or licence.
|
|
303
|
+
|
|
304
|
+
Fails CLOSED on an unrecognised value. A registry that says `"licenced"`
|
|
305
|
+
must not hand out a proprietary model because of a spelling.
|
|
306
|
+
|
|
307
|
+
False for `private`: those weights are downloadable, by the holder of a
|
|
308
|
+
token we authorised. Proprietary and unobtainable are different facts, and
|
|
309
|
+
this function answers the second one — `requires_auth` answers the first.
|
|
310
|
+
"""
|
|
311
|
+
return availability(model) not in (PUBLIC, PRIVATE)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def requires_auth(model: Optional[str]) -> bool:
|
|
315
|
+
"""True if pulling these weights needs a token we authorised.
|
|
316
|
+
|
|
317
|
+
Separate from `is_licensed` because the two diverge exactly where it
|
|
318
|
+
matters: a `private` model needs a token AND can be fetched with one, a
|
|
319
|
+
`licensed` model needs no token because there is nothing to fetch.
|
|
320
|
+
"""
|
|
321
|
+
return availability(model) == PRIVATE
|
|
322
|
+
|
|
323
|
+
|
|
250
324
|
# ── prompt sets ─────────────────────────────────────────────────────────────
|
|
251
325
|
|
|
252
326
|
@dataclass
|
|
@@ -479,6 +553,114 @@ def pixel_budget(model: Optional[str]) -> PixelBudget:
|
|
|
479
553
|
return DEFAULT_PIXEL_BUDGET
|
|
480
554
|
|
|
481
555
|
|
|
556
|
+
# ─── expert routing: WHICH ADAPTER answers this prompt family ───────────────
|
|
557
|
+
#
|
|
558
|
+
# Same registry, same reason again, one more axis. A routed model is not one
|
|
559
|
+
# adapter: `Abyss` is four LoRAs behind one endpoint, and the thing that picks
|
|
560
|
+
# between them is the prompt family the request is about to send — grid, pixel,
|
|
561
|
+
# video, text. See docs/MOE_LORA_DESIGN.md §11 ("the router is the prompt
|
|
562
|
+
# family, and it already exists").
|
|
563
|
+
#
|
|
564
|
+
# It lives HERE rather than in the solver for the reason prompt_version does:
|
|
565
|
+
# which experts exist is a fact about the MODEL. A client that hardcoded four
|
|
566
|
+
# names would send them to v1.2, which serves one adapter under one name, and
|
|
567
|
+
# every request would 404 on a model the endpoint has never heard of.
|
|
568
|
+
#
|
|
569
|
+
# ABSENT MEANS NOT ROUTED, and that is the whole backwards-compatibility story:
|
|
570
|
+
# every published model today declares no `experts`, so `experts()` returns {},
|
|
571
|
+
# `route()` returns the name the caller already had, and the bytes on the wire
|
|
572
|
+
# are identical.
|
|
573
|
+
|
|
574
|
+
EXPERT_ENV = "CAPTCHA_EXPERT"
|
|
575
|
+
|
|
576
|
+
#: The prompt families, which are also the only keys an `experts` map may
|
|
577
|
+
#: carry. `pixel` is the click/drag family — `PromptSet.action_prompt` — and is
|
|
578
|
+
#: spelled that way rather than `action_pixel` because it is what ckgate's
|
|
579
|
+
#: PROMPT_MARKERS already calls it, and two spellings of one router is how a
|
|
580
|
+
#: family ends up unrouted on one side.
|
|
581
|
+
PROMPT_FAMILIES = ("pixel", "grid", "video", "text")
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def experts(model: Optional[str]) -> Dict[str, str]:
|
|
585
|
+
"""Prompt family -> served adapter name, for a ROUTED model.
|
|
586
|
+
|
|
587
|
+
`{}` for anything that is not routed, which is every model published so
|
|
588
|
+
far. Callers apply it unconditionally; an empty map is a no-op.
|
|
589
|
+
|
|
590
|
+
Unknown family keys are DROPPED rather than passed through. A typo in the
|
|
591
|
+
registry would otherwise be a name nothing ever selects — invisible,
|
|
592
|
+
because the fallback below is a working solve at a slightly worse score,
|
|
593
|
+
which is the exact failure mode this whole module exists to prevent.
|
|
594
|
+
"""
|
|
595
|
+
repo_id = canonical_model_id(model)
|
|
596
|
+
entry = (registered_models().get(repo_id) or {}) if repo_id else {}
|
|
597
|
+
mapping = entry.get("experts")
|
|
598
|
+
if not isinstance(mapping, dict):
|
|
599
|
+
return {}
|
|
600
|
+
out = {}
|
|
601
|
+
for family in PROMPT_FAMILIES:
|
|
602
|
+
name = mapping.get(family)
|
|
603
|
+
if isinstance(name, str) and name:
|
|
604
|
+
out[family] = name
|
|
605
|
+
for key in mapping:
|
|
606
|
+
if key not in PROMPT_FAMILIES and not str(key).startswith("_"):
|
|
607
|
+
_warn(f"model {repo_id!r} declares an expert for unknown prompt "
|
|
608
|
+
f"family {key!r}; have {', '.join(PROMPT_FAMILIES)}. It will "
|
|
609
|
+
"never be selected.")
|
|
610
|
+
return out
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def route(model: Optional[str], family: Optional[str], *,
|
|
614
|
+
pin: Optional[str] = None) -> str:
|
|
615
|
+
"""The `model` string to put on the wire for one request.
|
|
616
|
+
|
|
617
|
+
`model` is what the caller would have sent — a served name or a repo id —
|
|
618
|
+
and is returned unchanged whenever the model is not routed, which is what
|
|
619
|
+
keeps every existing deployment byte-identical.
|
|
620
|
+
|
|
621
|
+
`pin` forces one family for every request, whatever `family` says. That is
|
|
622
|
+
what a gate wants: serve one arm, grade the types it owns, serve the next.
|
|
623
|
+
An unknown `pin` RAISES, because a benchmark that silently measured the
|
|
624
|
+
generalist and reported it as the expert is a number nobody can catch.
|
|
625
|
+
|
|
626
|
+
An unrecognised or unmapped `family` degrades to `model` — the generalist —
|
|
627
|
+
never to another expert and never to an error. A caller prompting in their
|
|
628
|
+
own words is the expected case for a shipped model, and the day the
|
|
629
|
+
generation-2 `text` family reached ckgate without a marker, refusing it cost
|
|
630
|
+
every distorted-text solve in production for a day.
|
|
631
|
+
"""
|
|
632
|
+
if pin is not None:
|
|
633
|
+
pin = pin.strip()
|
|
634
|
+
if pin:
|
|
635
|
+
if pin not in PROMPT_FAMILIES:
|
|
636
|
+
raise ValueError(
|
|
637
|
+
f"unknown expert {pin!r}; have {', '.join(PROMPT_FAMILIES)}")
|
|
638
|
+
family = pin
|
|
639
|
+
mapping = experts(model)
|
|
640
|
+
if not mapping:
|
|
641
|
+
if pin:
|
|
642
|
+
raise ValueError(
|
|
643
|
+
f"expert {pin!r} was requested but model {model!r} declares no "
|
|
644
|
+
"experts — it serves one adapter under one name. Drop the "
|
|
645
|
+
"expert, or point the client at a routed model.")
|
|
646
|
+
return model or ""
|
|
647
|
+
return mapping.get(family or "") or model or ""
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _env_str(name: str) -> Optional[str]:
|
|
651
|
+
"""Like `_env_int`, and read through the same indirection for the same
|
|
652
|
+
second reason: `test_public_contract.py` finds environment variables by
|
|
653
|
+
grepping for `os.environ.get(<NAME>)`, so naming the CONSTANT there records
|
|
654
|
+
`EXPERT_ENV` in the published env list as though it were a variable anyone
|
|
655
|
+
could set. The literal is picked up from the assignment above instead."""
|
|
656
|
+
return (os.environ.get(name) or "").strip() or None
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def expert_pin() -> Optional[str]:
|
|
660
|
+
"""`CAPTCHA_EXPERT`, or None. Empty and unset are the same thing."""
|
|
661
|
+
return _env_str(EXPERT_ENV)
|
|
662
|
+
|
|
663
|
+
|
|
482
664
|
def clear_cache() -> None:
|
|
483
665
|
_cache.clear()
|
|
484
666
|
global _REGISTRY
|
|
@@ -170,11 +170,16 @@ def ensure_server(base_url: "str | None" = None) -> None:
|
|
|
170
170
|
"""
|
|
171
171
|
base_url = base_url or config.base_url()
|
|
172
172
|
|
|
173
|
-
|
|
174
|
-
|
|
173
|
+
# REMOTE FIRST. There is nothing to ensure about a server we do not manage,
|
|
174
|
+
# so asking it for /health before deciding that is a round trip spent to
|
|
175
|
+
# reach a `return` — and up to `timeout=2.0` of it against a hosted gateway
|
|
176
|
+
# that serves no /health at all. Paid once per ActionPlanner, which for a
|
|
177
|
+
# caller using `solve_captcha_on_page` is once per solve.
|
|
175
178
|
if not is_local(base_url):
|
|
176
|
-
#
|
|
177
|
-
#
|
|
179
|
+
# Let the actual request raise a clear connection error rather than
|
|
180
|
+
# silently trying to boot vLLM locally.
|
|
181
|
+
return
|
|
182
|
+
if is_healthy(base_url):
|
|
178
183
|
return
|
|
179
184
|
if not config.autostart_enabled():
|
|
180
185
|
return
|
|
@@ -24,7 +24,6 @@ v1 had a SAM3-backed tool-using planner with detect/segment/drag-refine; it
|
|
|
24
24
|
lives on the `v1-old-architecture` branch.
|
|
25
25
|
"""
|
|
26
26
|
|
|
27
|
-
import math
|
|
28
27
|
import os
|
|
29
28
|
import shutil
|
|
30
29
|
import sys
|
|
@@ -112,6 +111,47 @@ class DebugManager:
|
|
|
112
111
|
return None
|
|
113
112
|
|
|
114
113
|
|
|
114
|
+
#: The only cell counts a registered grid puzzle has. `find_grid` is a lattice
|
|
115
|
+
#: detector, not a catalogue — it allows rectangles and any dimension from
|
|
116
|
+
#: MIN_GRID_DIM up — so it will happily report 12 or 20 cells on a click puzzle's
|
|
117
|
+
#: header/footer bands. Anything not in here is a false positive by construction,
|
|
118
|
+
#: and it is also a board the model has never seen: the training pipeline's own
|
|
119
|
+
#: `grid_overlay.detect_cells` refuses every count but these two.
|
|
120
|
+
GRID_CELL_SHAPES = {9: (3, 3), 16: (4, 4)}
|
|
121
|
+
|
|
122
|
+
#: Cell counts each vendor actually ships, for the vendors the driver can name.
|
|
123
|
+
#: Only reCAPTCHA has a 4x4; hCaptcha's single grid puzzle is 3x3, so 16 cells on
|
|
124
|
+
#: an hCaptcha board is a contradiction rather than a close call. Measured on real
|
|
125
|
+
#: captures, that one line removes four of the five non-grid boards that clear
|
|
126
|
+
#: both find_grid and _is_real_grid today.
|
|
127
|
+
#:
|
|
128
|
+
#: A vendor NOT in this table — `unknown`, or anything new — gets every shape.
|
|
129
|
+
#: `unknown` is what the driver reports for GeeTest and Prosopo, both of which
|
|
130
|
+
#: ship real 3x3 grids, and it is what the one-shot image API defaults to, which
|
|
131
|
+
#: is the path the offline grader uses to score reCAPTCHA 4x4. Narrowing it would
|
|
132
|
+
#: stop scoring a whole puzzle type while looking like a tightening.
|
|
133
|
+
VENDOR_GRID_CELLS = {
|
|
134
|
+
"hcaptcha": frozenset({9}),
|
|
135
|
+
"recaptcha": frozenset({9, 16}),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _grid_dims(n_cells, puzzle_source="unknown"):
|
|
140
|
+
"""(rows, cols) if this detection could be a grid we ship, else None.
|
|
141
|
+
|
|
142
|
+
None means "do not solve this as a grid" — the caller falls through to the
|
|
143
|
+
click/drag path, which is what the board actually is. It is deliberately not
|
|
144
|
+
an exception: a false positive here is an ordinary event, not an error.
|
|
145
|
+
"""
|
|
146
|
+
shape = GRID_CELL_SHAPES.get(n_cells)
|
|
147
|
+
if shape is None:
|
|
148
|
+
return None
|
|
149
|
+
allowed = VENDOR_GRID_CELLS.get(puzzle_source)
|
|
150
|
+
if allowed is not None and n_cells not in allowed:
|
|
151
|
+
return None
|
|
152
|
+
return shape
|
|
153
|
+
|
|
154
|
+
|
|
115
155
|
class CaptchaSolver:
|
|
116
156
|
"""v2 solver: OpenCV grid detection + vLLM `captcha` LoRA."""
|
|
117
157
|
|
|
@@ -120,13 +160,15 @@ class CaptchaSolver:
|
|
|
120
160
|
model: Optional[str] = None,
|
|
121
161
|
provider: str = "captchaKrakenApi",
|
|
122
162
|
api_key: Optional[str] = None,
|
|
163
|
+
expert: Optional[str] = None,
|
|
123
164
|
):
|
|
124
165
|
self.debug = DebugManager(DEBUG)
|
|
125
166
|
# `provider` kept for argv compatibility with the v1 CLI signature.
|
|
126
167
|
if provider not in {"captchaKrakenApi"}:
|
|
127
168
|
self.debug.log(f"Provider {provider!r} ignored; v2 only supports captchaKrakenApi.")
|
|
128
169
|
self.planner = ActionPlanner(
|
|
129
|
-
model=model, api_key=api_key, debug_callback=self.debug.log
|
|
170
|
+
model=model, api_key=api_key, debug_callback=self.debug.log,
|
|
171
|
+
expert=expert,
|
|
130
172
|
)
|
|
131
173
|
self.image_processor = ImageProcessor(None, self.planner, self.debug)
|
|
132
174
|
self._image_size: Optional[Tuple[int, int]] = None
|
|
@@ -183,9 +225,22 @@ class CaptchaSolver:
|
|
|
183
225
|
|
|
184
226
|
with timed("solver.find_grid"):
|
|
185
227
|
grid_boxes = find_grid(cv_image_path)
|
|
186
|
-
|
|
228
|
+
# The SHAPE gate runs before the content gate: a lattice no vendor ships
|
|
229
|
+
# is not a grid whatever its cells look like, and saying so here keeps
|
|
230
|
+
# `_solve_grid` from ever being handed dimensions it would have to invent.
|
|
231
|
+
dims = _grid_dims(len(grid_boxes), puzzle_source) if grid_boxes else None
|
|
232
|
+
if grid_boxes and dims and self._is_real_grid(cv_image_path, grid_boxes):
|
|
187
233
|
self.debug.log(f"Detected grid with {len(grid_boxes)} cells")
|
|
188
|
-
return self._solve_grid(cv_image_path, grid_boxes,
|
|
234
|
+
return self._solve_grid(cv_image_path, grid_boxes, dims[0], dims[1],
|
|
235
|
+
retry_mode=retry_mode)
|
|
236
|
+
elif grid_boxes and not dims:
|
|
237
|
+
# e.g. a 16-cell lattice on an hCaptcha board (it ships no 4x4), or a
|
|
238
|
+
# 12-cell one on anything. Fall through to the click path, which is
|
|
239
|
+
# what this puzzle is.
|
|
240
|
+
self.debug.log(
|
|
241
|
+
f"find_grid returned {len(grid_boxes)} cells, which is not a shape "
|
|
242
|
+
f"{puzzle_source} ships — not solving as a grid."
|
|
243
|
+
)
|
|
189
244
|
elif grid_boxes:
|
|
190
245
|
# find_grid latched onto e.g. an hCaptcha click-puzzle's
|
|
191
246
|
# header/footer bands. Reject — only true grids are supported.
|
|
@@ -403,11 +458,6 @@ class CaptchaSolver:
|
|
|
403
458
|
if not stds:
|
|
404
459
|
return False
|
|
405
460
|
|
|
406
|
-
n = len(grid_boxes)
|
|
407
|
-
side = 3 if n == 9 else 4 if n == 16 else int(round(n ** 0.5))
|
|
408
|
-
rows = [stds[i * side:(i + 1) * side] for i in range(side)]
|
|
409
|
-
cols = [stds[i::side] for i in range(side)]
|
|
410
|
-
|
|
411
461
|
self.debug.log(
|
|
412
462
|
f"_is_real_grid: per-cell stddev = {[round(s, 1) for s in stds]}"
|
|
413
463
|
)
|
|
@@ -459,46 +509,35 @@ class CaptchaSolver:
|
|
|
459
509
|
)
|
|
460
510
|
return False
|
|
461
511
|
|
|
462
|
-
FLAT
|
|
463
|
-
#
|
|
464
|
-
flat_mask = [[1 if stds[r * side + c] < FLAT else 0
|
|
465
|
-
for c in range(side)] for r in range(side)]
|
|
466
|
-
flat_count = sum(sum(row) for row in flat_mask)
|
|
467
|
-
|
|
468
|
-
# Heuristic: in a *real* captcha grid, flat tiles (sky, asphalt)
|
|
469
|
-
# always form a spatially contiguous blob (top-left sky stack,
|
|
470
|
-
# bottom road row, etc.) because the underlying image is a single
|
|
471
|
-
# photo split into tiles. In a click-puzzle false grid, the flat
|
|
472
|
-
# cells are the **left + right margins** of the middle row,
|
|
473
|
-
# separated by the rich photo cell — non-contiguous.
|
|
512
|
+
# THE FLAT-CELL CONTIGUITY HEURISTIC IS GONE, and it was the only
|
|
513
|
+
# thing rejecting real grids.
|
|
474
514
|
#
|
|
475
|
-
#
|
|
476
|
-
#
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
return False
|
|
515
|
+
# It assumed flat tiles in a genuine photo grid always form ONE
|
|
516
|
+
# contiguous blob (a top-left sky stack, a bottom road row), because
|
|
517
|
+
# the board is a single photo cut up; a click-puzzle false grid puts
|
|
518
|
+
# its flat cells in the left and right margins of the middle row,
|
|
519
|
+
# which is two components. True of the boards it was written for,
|
|
520
|
+
# and false of plenty of photographs — a subject centred against
|
|
521
|
+
# plain sky leaves two flat corners and nothing between them.
|
|
522
|
+
#
|
|
523
|
+
# MEASURED over every real capture we hold, restricted to lattices
|
|
524
|
+
# `_grid_dims` actually accepts (9 or 16 cells — a 25-cell gobang
|
|
525
|
+
# board never reaches this function as a grid decision):
|
|
526
|
+
#
|
|
527
|
+
# rule grid false neg board false pos
|
|
528
|
+
# spread + mean-stddev 2/1748 0.11% 0/269 0.00%
|
|
529
|
+
# ...plus contiguity 40/1748 2.29% 0/269 0.00%
|
|
530
|
+
#
|
|
531
|
+
# It cost 38 real grids and caught nothing the two checks above did
|
|
532
|
+
# not already catch — 19 of those were recaptcha_grid_4x4 alone,
|
|
533
|
+
# 5.5% of that family, every one of them sent to the click path to
|
|
534
|
+
# be asked for coordinates on a board answered with cell numbers.
|
|
535
|
+
# Nothing errors when that happens; the reply is simply graded
|
|
536
|
+
# against a gold it cannot match.
|
|
537
|
+
#
|
|
538
|
+
# The median-colour spread check above supersedes it and is a much
|
|
539
|
+
# cleaner separation (an 18x gap rather than a topological guess),
|
|
540
|
+
# which is why this is a deletion and not a retuning.
|
|
502
541
|
return True
|
|
503
542
|
except Exception as e:
|
|
504
543
|
self.debug.log(f"_is_real_grid check errored ({e}); accepting grid.")
|
|
@@ -508,17 +547,18 @@ class CaptchaSolver:
|
|
|
508
547
|
self,
|
|
509
548
|
image_path: str,
|
|
510
549
|
grid_boxes: List[Tuple[int, int, int, int]],
|
|
550
|
+
rows: int,
|
|
551
|
+
cols: int,
|
|
511
552
|
retry_mode: Optional[str] = None,
|
|
512
553
|
) -> Union[ClickAction, DoneAction, WaitAction]:
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
self.debug.log(f"grid {rows}x{cols} ({n} cells)")
|
|
554
|
+
# The shape is PASSED IN, already cleared by `_grid_dims`. It used to be
|
|
555
|
+
# derived here, and the derivation had an `else` branch that turned any
|
|
556
|
+
# unrecognised count into `cols = int(sqrt(n)); rows = ceil(n / cols)` —
|
|
557
|
+
# so a 12-cell false positive became a 4x3 board, got stamped with twelve
|
|
558
|
+
# numbers, and was answered with cell ids. No such board can exist in the
|
|
559
|
+
# training corpus, because the generator's own detector refuses every
|
|
560
|
+
# count but 9 and 16.
|
|
561
|
+
self.debug.log(f"grid {rows}x{cols} ({len(grid_boxes)} cells)")
|
|
522
562
|
|
|
523
563
|
cv_selected: List[int] = []
|
|
524
564
|
cv_loading: List[int] = []
|
|
@@ -56,9 +56,14 @@ class PhaseBudget:
|
|
|
56
56
|
monitor spent 31s of it" is. Always on — it is a few dict updates per phase
|
|
57
57
|
against multi-second waits — but only PRINTED under CAPTCHA_TIMINGS=1.
|
|
58
58
|
|
|
59
|
-
Phases may nest (a burst contains its screenshots)
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
Phases may nest (a burst contains its screenshots). Only the outermost of a
|
|
60
|
+
GIVEN NAME accumulates, so re-entering one cannot double-count it — but a
|
|
61
|
+
phase nested inside a differently-named one counts under both, deliberately:
|
|
62
|
+
the cursor drifting over the widget WHILE the model generates is genuinely
|
|
63
|
+
both `mouse` and `inference`, and hiding either would misreport what the
|
|
64
|
+
solve was doing. The totals are therefore an attribution, not a partition,
|
|
65
|
+
and can exceed the elapsed time. `report()` prints the residual as
|
|
66
|
+
`(unattributed)` and it can go negative when they do.
|
|
62
67
|
"""
|
|
63
68
|
|
|
64
69
|
def __init__(self) -> None:
|