captchakraken 2.6.1 → 2.9.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 (38) hide show
  1. package/README.md +90 -7
  2. package/dist/cli-invocation.d.ts +16 -16
  3. package/dist/cli-invocation.js +33 -21
  4. package/dist/errors.d.ts +1 -1
  5. package/dist/humanize.d.ts +377 -0
  6. package/dist/humanize.js +716 -0
  7. package/dist/index.d.ts +33 -0
  8. package/dist/index.js +44 -1
  9. package/dist/model-name.d.ts +1 -1
  10. package/dist/model-name.js +1 -1
  11. package/dist/playwright-types.d.ts +18 -0
  12. package/dist/solver.d.ts +314 -16
  13. package/dist/solver.js +1208 -278
  14. package/dist/timing.d.ts +48 -0
  15. package/dist/timing.js +103 -0
  16. package/dist/trajectory.d.ts +28 -1
  17. package/dist/trajectory.js +105 -1
  18. package/dist/types.d.ts +113 -2
  19. package/package.json +2 -1
  20. package/python/README.md +43 -0
  21. package/python/pyproject.toml +1 -1
  22. package/python/src/captchakraken/__init__.py +36 -1
  23. package/python/src/captchakraken/cli.py +66 -4
  24. package/python/src/captchakraken/config.py +28 -2
  25. package/python/src/captchakraken/errors.py +24 -0
  26. package/python/src/captchakraken/humanize.py +929 -0
  27. package/python/src/captchakraken/keyframes.py +184 -12
  28. package/python/src/captchakraken/models.json +180 -18
  29. package/python/src/captchakraken/page_solver.py +1132 -321
  30. package/python/src/captchakraken/pinned_model.json +3 -2
  31. package/python/src/captchakraken/planner.py +204 -16
  32. package/python/src/captchakraken/prompts.py +186 -4
  33. package/python/src/captchakraken/server_manager.py +9 -4
  34. package/python/src/captchakraken/solver.py +98 -58
  35. package/python/src/captchakraken/timing.py +8 -3
  36. package/python/src/captchakraken/tool_calls/find_grid.py +357 -20
  37. package/python/src/captchakraken/trajectory.py +117 -0
  38. package/python/src/captchakraken/updater.py +62 -1
@@ -505,6 +505,8 @@ def _handle_solve_animated() -> bool:
505
505
  "rate the training data was collected at).")
506
506
  parser.add_argument("--model", default=None)
507
507
  parser.add_argument("--api-key", default=None)
508
+ parser.add_argument("--expert", default=None,
509
+ choices=["pixel", "grid", "video", "text"])
508
510
  args = parser.parse_args(sys.argv[2:])
509
511
 
510
512
  frames = _frames_in(args.frames_dir)
@@ -527,7 +529,8 @@ def _handle_solve_animated() -> bool:
527
529
  paths = [str(os.path.abspath(p))
528
530
  for p in write_keyframes(kfset, kf_dir, stem="challenge")]
529
531
 
530
- solver = CaptchaSolver(model=args.model, api_key=args.api_key)
532
+ solver = CaptchaSolver(model=args.model, api_key=args.api_key,
533
+ expert=args.expert)
531
534
  result = solver.solve_keyframes(paths)
532
535
 
533
536
  action_data = [a.model_dump() for a in result]
@@ -539,6 +542,10 @@ def _handle_solve_animated() -> bool:
539
542
  # solve log rather than only inferable from the answer.
540
543
  "keyframes": paths,
541
544
  "keyframe_mode": kfset.mode,
545
+ # What the board LOOKS like, as distinct from how it was
546
+ # sliced. The driver gates its clicks on this; see
547
+ # KeyframeSet.steady_screens.
548
+ "steady_screens": kfset.steady_screens,
542
549
  "source_frames": len(imgs),
543
550
  }))
544
551
  except UnsupportedCaptchaError as e:
@@ -630,6 +637,44 @@ def _handle_match_region() -> bool:
630
637
  return True
631
638
 
632
639
 
640
+ def _handle_report_outcome() -> bool:
641
+ """`captchakraken report-outcome <session-id> solved|failed`
642
+
643
+ Tell the hosted API whether the widget accepted this solve. The one fact the
644
+ server cannot see for itself: a wrong answer arrives there as a 200 with
645
+ well-formed JSON in it, and only the driver watched the widget.
646
+
647
+ THIS EXISTS FOR THE TypeScript PORT. The Python driver calls
648
+ `planner.report_outcome` in-process at the end of `PageSolver.solve`; the TS
649
+ driver spawns the CLI, which is the same subprocess boundary every other
650
+ model call already crosses. One implementation of the endpoint, the
651
+ credential and the extra headers, rather than a second copy in TypeScript
652
+ that agrees with this one until one of them is edited — the drift
653
+ `model-name.ts` already exists to prevent, on the same seam.
654
+
655
+ ALWAYS EXITS 0. The caller is a `finally` block in a driver whose solve has
656
+ already finished, so there is no outcome here worth failing over, and a
657
+ non-zero exit would make a self-hosted user's logs report an error on every
658
+ solve. What happened is on stdout for anyone who wants it.
659
+ """
660
+ if len(sys.argv) <= 1 or sys.argv[1] != "report-outcome":
661
+ return False
662
+ if len(sys.argv) < 4 or sys.argv[3] not in ("solved", "failed"):
663
+ print(json.dumps({
664
+ "error": "Usage: captchakraken report-outcome <session-id> solved|failed"
665
+ }), file=sys.stderr)
666
+ sys.exit(1)
667
+ delivered = False
668
+ try:
669
+ from .planner import ActionPlanner
670
+ delivered = ActionPlanner().report_outcome(sys.argv[2], sys.argv[3] == "solved")
671
+ except Exception as exc: # noqa: BLE001 — see the docstring
672
+ print(json.dumps({"reported": False, "error": str(exc)}))
673
+ return True
674
+ print(json.dumps({"reported": bool(delivered)}))
675
+ return True
676
+
677
+
633
678
  def _handle_tool_commands() -> bool:
634
679
  if len(sys.argv) <= 1:
635
680
  return False
@@ -841,6 +886,8 @@ def main():
841
886
  return
842
887
  if _handle_track_piece():
843
888
  return
889
+ if _handle_report_outcome():
890
+ return
844
891
 
845
892
  parser = argparse.ArgumentParser(description="CaptchaKraken v2 (vLLM)")
846
893
  parser.add_argument("image_path", help="Path to the captcha image or video")
@@ -867,8 +914,11 @@ def main():
867
914
  "--puzzle-source",
868
915
  default="unknown",
869
916
  choices=["hcaptcha", "recaptcha", "unknown"],
870
- help="Vendor hint from the Playwright wrapper. hCaptcha skips grid detection "
871
- "(find_grid false-positives on the header/footer bands of click puzzles).",
917
+ help="Vendor hint from the Playwright wrapper. Constrains which grid "
918
+ "shapes a detection may be solved as: hCaptcha ships only a 3x3, so a "
919
+ "16-cell lattice on one is a false positive (find_grid latches onto the "
920
+ "header/footer bands of click puzzles). An unrecognised or absent hint "
921
+ "allows every shape, which is what GeeTest and Prosopo boards report.",
872
922
  )
873
923
  parser.add_argument(
874
924
  "--retry-mode",
@@ -879,6 +929,17 @@ def main():
879
929
  "images'). Switches the grid prompt to a more aggressive variant that "
880
930
  "instructs the LoRA to look at the full grid for tiles it missed.",
881
931
  )
932
+ parser.add_argument(
933
+ "--expert",
934
+ default=None,
935
+ choices=["pixel", "grid", "video", "text"],
936
+ help="Force one expert of a ROUTED model (Abyss) instead of letting the "
937
+ "prompt family choose. Routing is automatic and this is the override a "
938
+ "benchmark needs: serve one arm, grade the types it owns, serve the "
939
+ "next. Refused against a model that serves a single adapter, because "
940
+ "quietly measuring the generalist and reporting it as the expert is a "
941
+ "number nobody can catch. Also settable as CAPTCHA_EXPERT.",
942
+ )
882
943
  parser.add_argument(
883
944
  "--text-mode",
884
945
  action="store_true",
@@ -897,7 +958,8 @@ def main():
897
958
 
898
959
  try:
899
960
  with timed("cli.total"):
900
- solver = CaptchaSolver(model=args.model, api_key=args.api_key)
961
+ solver = CaptchaSolver(model=args.model, api_key=args.api_key,
962
+ expert=args.expert)
901
963
  result = solver.solve(
902
964
  args.image_path,
903
965
  puzzle_source=args.puzzle_source,
@@ -193,11 +193,37 @@ def api_key() -> str:
193
193
  # CaptchaKraken_v1.1, so nothing moves until a newer model is registered — at
194
194
  # which point that IS the intent.
195
195
  def _registry_default(field: str) -> Optional[str]:
196
- """`field` from the registry's `latest` model entry, or None."""
196
+ """`field` from the entry of the model this process will actually serve.
197
+
198
+ THE PIN DECIDES THE ENTRY, not just the adapter. This read `latest`
199
+ unconditionally, so `CAPTCHA_LORA_ADAPTER` moved the adapter and nothing
200
+ else: the base weights to load it onto, the served `lora_name` to put in
201
+ the request, and the revision all still came from whichever model happened
202
+ to be `latest`.
203
+
204
+ That was survivable while every registered model was a 9B on one base
205
+ answering to one served name. It stopped being survivable the day the
206
+ registry gained `CaptchaKraken/Abyss-27B` — a Qwen3.8-**27B** — because
207
+ pinning it downloaded a 9B base, tried to load a 27B adapter onto it, and
208
+ sent `captcha-v12` as the model name. Nothing in the client errors on that;
209
+ it errors deep inside vLLM as a shape mismatch, or not at all if the
210
+ endpoint happens to serve something by that name. Same shape for every
211
+ expert arm, whose whole point is a `lora_name` of its own.
212
+
213
+ An UNREGISTERED pin still falls back to `latest`. A self-hoster's own
214
+ adapter is not in our registry and never will be; changing what they
215
+ resolve to would be a break with no benefit.
216
+ """
197
217
  try:
198
218
  from . import prompts # local: prompts imports nothing from config
199
219
 
200
- entry = prompts.registered_models().get(prompts.latest_model() or "")
220
+ models = prompts.registered_models()
221
+ entry = None
222
+ pin = os.getenv("CAPTCHA_LORA_ADAPTER") or os.getenv("CAPTCHA_LORA_NAME")
223
+ if pin:
224
+ entry = models.get(prompts.canonical_model_id(pin) or "")
225
+ if entry is None:
226
+ entry = models.get(prompts.latest_model() or "")
201
227
  value = (entry or {}).get(field)
202
228
  return value if isinstance(value, str) else None
203
229
  except Exception: # noqa: BLE001 — a broken registry falls back, never raises
@@ -149,6 +149,30 @@ def _sentence(code: str, message: str, url: Optional[str], retry: Optional[float
149
149
  "our side, not yours — retry shortly."
150
150
  )
151
151
 
152
+ # The two LICENSED-MODEL refusals. Both are 'you named a model', and they
153
+ # are separate codes because the fix is completely different: one is a
154
+ # licence to obtain, the other is a fleet that has not started serving yet
155
+ # and nothing for the caller to do. Generic prose collapses them into "the
156
+ # solve failed", which is the reading that sends someone to buy a licence
157
+ # they already hold.
158
+ if code == "model_not_licensed":
159
+ return (
160
+ "CaptchaKraken: the model this request named is licensed, and this "
161
+ "account is not licensed for it. The request was refused rather than "
162
+ "answered by a different model — a silent substitution would be a "
163
+ "score you could not explain. Unset CAPTCHA_LORA_NAME (or the "
164
+ f"client's `model`) to use the standard hosted model. {message} "
165
+ f"Licensing: {url or _SUPPORT}."
166
+ )
167
+
168
+ if code == "model_not_serving":
169
+ return (
170
+ "CaptchaKraken: this account IS licensed for the model it named, but "
171
+ "the fleet is not serving it yet. Nothing is wrong with your account "
172
+ "and there is nothing to buy. Unset CAPTCHA_LORA_NAME (or the "
173
+ f"client's `model`) to use the standard hosted model meanwhile. {message}"
174
+ )
175
+
152
176
  # `unrecognized_prompt`, `invalid_request`, and anything added after this was
153
177
  # written. The server's own message is the best thing available, so it is
154
178
  # carried through verbatim rather than replaced with a guess.