captchakraken 2.3.0 → 2.6.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 (54) hide show
  1. package/LICENSE +47 -7
  2. package/README.md +24 -4
  3. package/dist/cli-invocation.d.ts +46 -0
  4. package/dist/cli-invocation.js +68 -0
  5. package/dist/errors.d.ts +45 -0
  6. package/dist/errors.js +74 -0
  7. package/dist/index.d.ts +50 -0
  8. package/dist/index.js +52 -1
  9. package/dist/limits.d.ts +51 -0
  10. package/dist/limits.js +54 -0
  11. package/dist/model-name.d.ts +23 -0
  12. package/dist/model-name.js +70 -0
  13. package/dist/playwright-types.d.ts +28 -0
  14. package/dist/puppeteer-adapter.d.ts +11 -0
  15. package/dist/puppeteer-adapter.js +23 -0
  16. package/dist/python-command.d.ts +50 -0
  17. package/dist/python-command.js +60 -0
  18. package/dist/slide-geometry.d.ts +33 -0
  19. package/dist/slide-geometry.js +64 -0
  20. package/dist/solver.d.ts +214 -12
  21. package/dist/solver.js +1297 -165
  22. package/dist/trajectory.d.ts +31 -0
  23. package/dist/trajectory.js +162 -0
  24. package/dist/types.d.ts +128 -11
  25. package/dist/watcher.d.ts +99 -0
  26. package/dist/watcher.js +129 -0
  27. package/package.json +2 -5
  28. package/python/LICENSE +47 -7
  29. package/python/README.md +25 -8
  30. package/python/examples/README.md +36 -0
  31. package/python/examples/_harness.py +182 -94
  32. package/python/examples/demoHcaptcha.py +2 -1
  33. package/python/examples/demoRecaptcha.py +2 -1
  34. package/python/examples/watch_playwright.py +56 -0
  35. package/python/examples/with_playwright.py +43 -0
  36. package/python/pyproject.toml +4 -3
  37. package/python/src/captchakraken/__init__.py +5 -3
  38. package/python/src/captchakraken/action_types.py +24 -3
  39. package/python/src/captchakraken/cli.py +224 -2
  40. package/python/src/captchakraken/config.py +119 -17
  41. package/python/src/captchakraken/errors.py +195 -0
  42. package/python/src/captchakraken/keyframes.py +690 -0
  43. package/python/src/captchakraken/models.json +120 -0
  44. package/python/src/captchakraken/page_solver.py +1465 -102
  45. package/python/src/captchakraken/pinned_model.json +5 -7
  46. package/python/src/captchakraken/planner.py +328 -73
  47. package/python/src/captchakraken/prompts.py +485 -0
  48. package/python/src/captchakraken/solver.py +182 -11
  49. package/python/src/captchakraken/timing.py +70 -0
  50. package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
  51. package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
  52. package/python/src/captchakraken/trajectory.py +2 -7
  53. package/python/src/captchakraken/watcher.py +193 -0
  54. package/python/src/captchakraken/planner_types.py +0 -74
@@ -4,13 +4,14 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "captchakraken"
7
- version = "2.3.0"
7
+ version = "2.6.0"
8
8
  description = "Self-hosted captcha solver: OpenCV grid detection + a fine-tuned Qwen3.5-9B vision LoRA served on vLLM."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  # Source-available, NOT GPL. The CaptchaKraken Source-Available License permits
12
- # commercial use as an internal component but prohibits selling the solve itself
13
- # or thin-wrapper resale — see LICENSE. (GPL-3.0 would wrongly permit reselling.)
12
+ # commercial use as an internal component but prohibits selling the solve itself,
13
+ # thin-wrapper resale, and bundling it into a stealth browser someone else ships
14
+ # — see LICENSE. (GPL-3.0 would wrongly permit all three.)
14
15
  license = { file = "LICENSE" }
15
16
  authors = [{ name = "Jake Writer" }]
16
17
  keywords = ["captcha", "recaptcha", "hcaptcha", "vllm", "qwen", "computer-vision", "automation"]
@@ -9,8 +9,7 @@ Usage:
9
9
 
10
10
  Model/endpoint defaults live in `captchakraken.config` and are fully
11
11
  env-overridable (VLLM_BASE_URL, CAPTCHA_LORA_ADAPTER, …); the solver itself is
12
- model-agnostic. The legacy v1 stack (SAM3 grounding, multi-provider planner,
13
- detect/segment/drag-refine) lives on the `v1-old-architecture` branch.
12
+ model-agnostic.
14
13
  """
15
14
 
16
15
  from pathlib import Path
@@ -46,11 +45,13 @@ try: # pragma: no cover - exercised only when the serving stack is installed
46
45
  # caller supplies the Playwright-compatible page (see page_solver's module
47
46
  # docstring on why we duck-type rather than import one).
48
47
  from .page_solver import PageSolver, SolveResult, solve_captcha_on_page
48
+ from .watcher import CaptchaWatcher
49
49
  except ModuleNotFoundError:
50
50
  ActionPlanner = None # type: ignore[assignment,misc]
51
51
  CaptchaSolver = None # type: ignore[assignment,misc]
52
52
  solve_captcha = None # type: ignore[assignment]
53
53
  PageSolver = None # type: ignore[assignment,misc]
54
+ CaptchaWatcher = None # type: ignore[assignment,misc]
54
55
  SolveResult = None # type: ignore[assignment,misc]
55
56
  solve_captcha_on_page = None # type: ignore[assignment]
56
57
 
@@ -58,6 +59,7 @@ __all__ = [
58
59
  "CaptchaSolver",
59
60
  "solve_captcha",
60
61
  "PageSolver",
62
+ "CaptchaWatcher",
61
63
  "SolveResult",
62
64
  "solve_captcha_on_page",
63
65
  "ActionPlanner",
@@ -70,4 +72,4 @@ __all__ = [
70
72
  "add_overlays_to_image",
71
73
  ]
72
74
 
73
- __version__ = "2.3.0"
75
+ __version__ = "2.6.0"
@@ -22,6 +22,22 @@ class BoundingBox(RootModel):
22
22
 
23
23
  class Action(BaseModel):
24
24
  action: str
25
+ # ANIMATED CHALLENGES ONLY. Path to the keyframe the model chose to act on, and
26
+ # its 1-based number in the set that was sent.
27
+ #
28
+ # Why an action carries a picture: on an animated puzzle the target is only
29
+ # present part of the time, so these coordinates are correct only while the
30
+ # widget looks the way it did in that keyframe. The driver therefore holds the
31
+ # mouse until the live neighbourhood around the click point matches the same
32
+ # neighbourhood of this file, then clicks. Without it the click fires on
33
+ # whatever happens to be on screen, which for a fading sprite is usually
34
+ # background.
35
+ #
36
+ # Absent on every still puzzle, where there is no moment to wait for. Both are
37
+ # set together or not at all — a number with no image cannot be waited on, and
38
+ # an image with no number cannot be reported.
39
+ await_keyframe: Optional[str] = None
40
+ frame: Optional[int] = None
25
41
 
26
42
 
27
43
  class ClickAction(Action):
@@ -31,15 +47,20 @@ class ClickAction(Action):
31
47
 
32
48
  class DragAction(Action):
33
49
  action: Literal["drag"]
34
- source_bounding_box: BoundingBox = None # [x1, y1, x2, y2] in percentages
35
- target_bounding_box: BoundingBox = None # [x1, y1, x2, y2] in percentages
50
+ # Optional, not merely defaulted: a PUZZLE-PIECE SLIDER is carried as a drag
51
+ # with source_bounding_box explicitly None, and pydantic validates a value
52
+ # that is passed even when it equals the default. Declaring these as bare
53
+ # `BoundingBox = None` made constructing a slide raise ValidationError —
54
+ # every slide puzzle, at the moment of acting on a correct answer.
55
+ source_bounding_box: Optional[BoundingBox] = None # [x1, y1, x2, y2] in percentages
56
+ target_bounding_box: Optional[BoundingBox] = None # [x1, y1, x2, y2] in percentages
36
57
 
37
58
 
38
59
  class TypeAction(Action):
39
60
  """Type text into an input."""
40
61
  action: Literal["type"]
41
62
  text: str
42
- target_bounding_box: BoundingBox = None # [x1, y1, x2, y2] in percentages
63
+ target_bounding_box: Optional[BoundingBox] = None # [x1, y1, x2, y2] in percentages
43
64
 
44
65
 
45
66
  class WaitAction(Action):
@@ -3,12 +3,25 @@ CaptchaKraken CLI (v2).
3
3
 
4
4
  Modes:
5
5
  captchakraken image.png [model_name] [api_provider] [api_key]
6
- Solve a captcha image / video. `api_provider` is kept for v1
6
+ Solve a captcha from ONE still image. `api_provider` is kept for v1
7
7
  compat; only `captchaKrakenApi` is supported and is the default.
8
8
 
9
+ captchakraken solve-animated --frames-dir DIR [--fps N] [--model M] [--api-key K]
10
+ Solve an ANIMATED challenge from a recorded burst. DIR holds one
11
+ zero-padded PNG per frame; the burst is sliced into keyframes (written to
12
+ DIR/keyframes/) and sent to the model as one multi-image request. Prints
13
+ {"actions": [...], "keyframes": [...], "keyframe_mode": ...}. Every action
14
+ carries `await_keyframe` — the still the driver must see on screen before
15
+ acting — and `frame`, its number. Poll for that state with `match-region`.
16
+
17
+ captchakraken match-region ref.png live.png cx cy [tolerance]
18
+ Does `live` look like `ref` around the 0-1 point (cx, cy)? The wait gate
19
+ behind an animated click -> {"match": bool, "diff": float}. Also a `serve`
20
+ cmd (`match-region`), which is what a driver polling every ~120ms wants.
21
+
9
22
  captchakraken check-movement img1.png img2.png [threshold]
10
23
  captchakraken check-movement-batch threshold img1 img2 [img3 ...]
11
- Frame-diff helpers used by the Playwright lib's video flow.
24
+ Frame-diff helpers used by the Playwright lib's settle monitor.
12
25
 
13
26
  captchakraken find-grid image.png
14
27
  captchakraken detect-selected image.png
@@ -55,7 +68,9 @@ import json
55
68
  import os
56
69
  import subprocess
57
70
  import sys
71
+ from typing import Optional
58
72
 
73
+ from .errors import CaptchaKrakenAPIError
59
74
  from .solver import CaptchaSolver, UnsupportedCaptchaError
60
75
  from .timing import timed
61
76
 
@@ -380,6 +395,8 @@ def _handle_serve() -> bool:
380
395
  grid-cell-states {a, b} -> states | {grid: null}
381
396
  grid-cell-states-fixed {a, b, grid_boxes} -> states
382
397
  check-movement {a, b, threshold?} -> {has_movement: bool}
398
+ match-region {ref, live, cx, cy, ...} -> {match, diff, ...}
399
+ track-piece {before, after, exclude?} -> {bbox, moved}
383
400
 
384
401
  Unknown cmd / malformed line -> an {ok:false} response (the process keeps
385
402
  running). EOF on stdin ends the loop cleanly. All heavy detection delegates
@@ -413,6 +430,18 @@ def _handle_serve() -> bool:
413
430
  threshold = float(req.get("threshold", 0.005))
414
431
  moved = ImageProcessor.detect_movement(req["a"], req["b"], threshold)
415
432
  return {"has_movement": bool(moved)}
433
+ if cmd == "match-region":
434
+ # Animated wait gate: is the widget back in the state the model
435
+ # answered about? Polled every ~120ms while a click is held, so it
436
+ # belongs on the worker rather than paying a process spawn per poll.
437
+ return _match_region(req["ref"], req["live"],
438
+ float(req["cx"]), float(req["cy"]),
439
+ req.get("tolerance"))
440
+ if cmd == "track-piece":
441
+ # Slider closed loop: where has the piece got to? Called several
442
+ # times per drag WITH THE MOUSE HELD DOWN, so a process spawn per
443
+ # reading would stretch the drag into something no human hand does.
444
+ return _track_piece(req["before"], req["after"], req.get("exclude"))
416
445
  raise ValueError(f"unknown cmd: {cmd!r}")
417
446
 
418
447
  # Signal readiness so the JS side knows imports are done before it polls.
@@ -435,6 +464,172 @@ def _handle_serve() -> bool:
435
464
  return True
436
465
 
437
466
 
467
+ def _frames_in(frames_dir: str) -> list:
468
+ """The recorded burst's frames in capture order.
469
+
470
+ Sorted by NAME, which is why the recorder must zero-pad: `frame_9.png` sorts
471
+ after `frame_10.png`, and a shuffled burst destroys the temporal structure the
472
+ cycle detector reads. `_handle_solve_animated` rejects an unsorted-looking set
473
+ rather than silently slicing noise.
474
+ """
475
+ import glob
476
+
477
+ return sorted(
478
+ p for ext in ("png", "jpg", "jpeg")
479
+ for p in glob.glob(os.path.join(frames_dir, f"*.{ext}"))
480
+ )
481
+
482
+
483
+ def _handle_solve_animated() -> bool:
484
+ """`captchakraken solve-animated --frames-dir DIR [--fps N] [...]`
485
+
486
+ The animated counterpart of the default solve command, for the TypeScript
487
+ driver: TS owns the browser, so IT records the burst (one zero-padded PNG per
488
+ frame into a directory it owns) and this slices the burst into keyframes, sends
489
+ them to the model as one multi-image request, and prints the actions.
490
+
491
+ Each action carries `await_keyframe`, an absolute path to the still the driver
492
+ must see on screen before it acts. Those files are written into
493
+ `DIR/keyframes/`, i.e. inside the caller's own directory — the CLI never leaves
494
+ temp files behind for the caller to guess at, and the caller cleans up when it
495
+ cleans up the burst. Poll for the state with `match-region`.
496
+ """
497
+ if len(sys.argv) <= 1 or sys.argv[1] != "solve-animated":
498
+ return False
499
+
500
+ parser = argparse.ArgumentParser(prog="captchakraken solve-animated")
501
+ parser.add_argument("--frames-dir", required=True,
502
+ help="Directory of the recorded burst (zero-padded PNGs).")
503
+ parser.add_argument("--fps", type=float, default=10.0,
504
+ help="Frame rate the burst was recorded at (default 10, the "
505
+ "rate the training data was collected at).")
506
+ parser.add_argument("--model", default=None)
507
+ parser.add_argument("--api-key", default=None)
508
+ args = parser.parse_args(sys.argv[2:])
509
+
510
+ frames = _frames_in(args.frames_dir)
511
+ if not frames:
512
+ print(json.dumps({"error": f"no frames in {args.frames_dir}"}), file=sys.stderr)
513
+ sys.exit(1)
514
+
515
+ try:
516
+ import cv2
517
+
518
+ from .keyframes import extract_keyframes, write_keyframes
519
+
520
+ with timed("cli.total"):
521
+ imgs = [im for im in (cv2.imread(p) for p in frames) if im is not None]
522
+ if not imgs:
523
+ print(json.dumps({"error": "no frame decoded"}), file=sys.stderr)
524
+ sys.exit(1)
525
+ kfset = extract_keyframes(imgs, fps=float(args.fps))
526
+ kf_dir = os.path.join(args.frames_dir, "keyframes")
527
+ paths = [str(os.path.abspath(p))
528
+ for p in write_keyframes(kfset, kf_dir, stem="challenge")]
529
+
530
+ solver = CaptchaSolver(model=args.model, api_key=args.api_key)
531
+ result = solver.solve_keyframes(paths)
532
+
533
+ action_data = [a.model_dump() for a in result]
534
+ print(json.dumps({
535
+ "actions": action_data,
536
+ "token_usage": solver.planner.token_usage,
537
+ # Reported so the driver can log what it actually sent, and so a
538
+ # surprising slicing (6 frames collapsing to 1) is visible in the
539
+ # solve log rather than only inferable from the answer.
540
+ "keyframes": paths,
541
+ "keyframe_mode": kfset.mode,
542
+ "source_frames": len(imgs),
543
+ }))
544
+ except UnsupportedCaptchaError as e:
545
+ print(json.dumps({"error": str(e), "unsupported": True}), file=sys.stderr)
546
+ sys.exit(2)
547
+ except CaptchaKrakenAPIError as e:
548
+ print(json.dumps(e.to_payload()), file=sys.stderr)
549
+ sys.exit(3)
550
+ except Exception as e: # noqa: BLE001
551
+ import traceback
552
+
553
+ traceback.print_exc()
554
+ print(json.dumps({"error": str(e)}), file=sys.stderr)
555
+ sys.exit(1)
556
+ return True
557
+
558
+
559
+ def _match_region(ref: str, live: str, cx: float, cy: float,
560
+ tolerance: Optional[float] = None) -> dict:
561
+ """Does `live` look like `ref` around the 0–1 point (cx, cy)?
562
+
563
+ The wait-for-state gate behind an animated click. Compares only the
564
+ neighbourhood of the action point, with the same box and metric the training
565
+ label's frame was chosen with (`keyframes.region_box` / `region_diff_ratio`) —
566
+ local rather than whole-frame because everything else in these puzzles is also
567
+ moving, so a whole-frame match would need every unrelated sprite to align too
568
+ and would essentially never open.
569
+ """
570
+ import cv2
571
+
572
+ from .keyframes import MATCH_REGION_TOLERANCE, region_box, region_diff_ratio
573
+
574
+ tol = MATCH_REGION_TOLERANCE if tolerance is None else float(tolerance)
575
+ a, b = cv2.imread(ref), cv2.imread(live)
576
+ if a is None or b is None:
577
+ return {"match": False, "diff": 1.0, "error": "unreadable image"}
578
+ box = region_box(a.shape[1::-1], (cx, cy))
579
+ d = region_diff_ratio(a, b, box)
580
+ return {"match": bool(d <= tol), "diff": float(d), "tolerance": tol}
581
+
582
+
583
+ def _track_piece(before: str, after: str, exclude=None) -> dict:
584
+ """Where did the puzzle piece get to? See tool_calls/track_piece.py."""
585
+ from .tool_calls.track_piece import changed_bbox
586
+
587
+ bbox = changed_bbox(before, after, exclude)
588
+ return {"bbox": bbox, "moved": bbox is not None}
589
+
590
+
591
+ def _handle_track_piece() -> bool:
592
+ """`captchakraken track-piece before.png after.png [exclude_json]`
593
+
594
+ `exclude_json` is `[x1, y1, x2, y2]` in pixels — the slider handle, which is
595
+ moving too and would otherwise be measured instead of the piece.
596
+
597
+ Also available over the persistent worker as cmd `track-piece`, which is
598
+ what the driver should use: this runs mid-drag, several times, with the
599
+ mouse button held.
600
+ """
601
+ if len(sys.argv) <= 1 or sys.argv[1] != "track-piece":
602
+ return False
603
+ if len(sys.argv) < 4:
604
+ print(json.dumps({
605
+ "error": "Usage: captchakraken track-piece before.png after.png [exclude_json]"
606
+ }), file=sys.stderr)
607
+ sys.exit(1)
608
+ exclude = json.loads(sys.argv[4]) if len(sys.argv) > 4 else None
609
+ print(json.dumps(_track_piece(sys.argv[2], sys.argv[3], exclude)))
610
+ return True
611
+
612
+
613
+ def _handle_match_region() -> bool:
614
+ """`captchakraken match-region ref.png live.png cx cy [tolerance]`
615
+
616
+ cx/cy are 0–1 fractions of the reference image (the centre of the action).
617
+ Also available over the persistent worker as cmd `match-region`, which is what
618
+ the driver should use — this is polled every ~120 ms while waiting.
619
+ """
620
+ if len(sys.argv) <= 1 or sys.argv[1] != "match-region":
621
+ return False
622
+ if len(sys.argv) < 6:
623
+ print(json.dumps({
624
+ "error": "Usage: captchakraken match-region ref.png live.png cx cy [tolerance]"
625
+ }), file=sys.stderr)
626
+ sys.exit(1)
627
+ tol = float(sys.argv[6]) if len(sys.argv) > 6 else None
628
+ print(json.dumps(_match_region(sys.argv[2], sys.argv[3],
629
+ float(sys.argv[4]), float(sys.argv[5]), tol)))
630
+ return True
631
+
632
+
438
633
  def _handle_tool_commands() -> bool:
439
634
  if len(sys.argv) <= 1:
440
635
  return False
@@ -640,6 +835,12 @@ def main():
640
835
  return
641
836
  if _handle_tool_commands():
642
837
  return
838
+ if _handle_solve_animated():
839
+ return
840
+ if _handle_match_region():
841
+ return
842
+ if _handle_track_piece():
843
+ return
643
844
 
644
845
  parser = argparse.ArgumentParser(description="CaptchaKraken v2 (vLLM)")
645
846
  parser.add_argument("image_path", help="Path to the captcha image or video")
@@ -678,6 +879,15 @@ def main():
678
879
  "images'). Switches the grid prompt to a more aggressive variant that "
679
880
  "instructs the LoRA to look at the full grid for tiles it missed.",
680
881
  )
882
+ parser.add_argument(
883
+ "--text-mode",
884
+ action="store_true",
885
+ help="The widget has a text box, so the answer is a string to type rather "
886
+ "than a place to click. Sends the distorted-text prompt and skips grid "
887
+ "detection. Set by the Playwright wrapper from the DOM — the picture "
888
+ "alone does not distinguish a BotDetect code from an hCaptcha "
889
+ "'click the matching letter'.",
890
+ )
681
891
 
682
892
  args = parser.parse_args()
683
893
 
@@ -692,6 +902,7 @@ def main():
692
902
  args.image_path,
693
903
  puzzle_source=args.puzzle_source,
694
904
  retry_mode=args.retry_mode,
905
+ text_mode=args.text_mode,
695
906
  )
696
907
 
697
908
  if isinstance(result, list):
@@ -707,6 +918,17 @@ def main():
707
918
  # checkboxes. Emit a clean error with no traceback.
708
919
  print(json.dumps({"error": str(e), "unsupported": True}), file=sys.stderr)
709
920
  sys.exit(2)
921
+ except CaptchaKrakenAPIError as e:
922
+ # The hosted API refused us and told us why. That is an answer, not a
923
+ # crash, so no traceback: a stack trace here buries the one line the
924
+ # user needs ("out of credits, top up at …") under frames from a module
925
+ # they have never opened.
926
+ #
927
+ # Exit 3, distinct from 1 (genuine failure) and 2 (unsupported puzzle),
928
+ # and the payload carries the machine-readable fields so the JS driver
929
+ # can rebuild the error rather than regex it out of prose.
930
+ print(json.dumps(e.to_payload()), file=sys.stderr)
931
+ sys.exit(3)
710
932
  except Exception as e:
711
933
  import traceback
712
934
 
@@ -27,7 +27,7 @@ import json
27
27
  import os
28
28
  from functools import lru_cache
29
29
  from pathlib import Path
30
- from typing import Any, Dict
30
+ from typing import Any, Dict, Optional
31
31
 
32
32
  _PINNED_MODEL_PATH = Path(__file__).with_name("pinned_model.json")
33
33
 
@@ -47,8 +47,31 @@ def pinned() -> Dict[str, Any]:
47
47
 
48
48
  # ── Inference endpoint ──────────────────────────────────────────────────────
49
49
  def base_url() -> str:
50
- """OpenAI-compatible endpoint, e.g. http://localhost:8000/v1."""
51
- return os.getenv("VLLM_BASE_URL", f"http://localhost:{port()}/v1")
50
+ """OpenAI-compatible endpoint, e.g. http://localhost:8000/v1.
51
+
52
+ Precedence mirrors `api_key()` exactly — env, then the credentials file,
53
+ then the local default — because the two values are set together and a
54
+ reader who has learned one ordering should not have to learn a second.
55
+
56
+ 1. VLLM_BASE_URL. An explicit override always wins; self-hosters and the
57
+ dev environment depend on this and must not be silently redirected.
58
+ 2. The credentials file. Written by the MCP signup flow alongside the key,
59
+ so onboarding configures the endpoint in the same step and a hosted
60
+ user needs no env plumbing at all.
61
+ 3. localhost. Unchanged, and still correct: someone with no credentials
62
+ file is by definition not a hosted user, so the self-hosted default is
63
+ the right guess for them.
64
+
65
+ The bug this closes: a camoufox user who signed up through the MCP had a
66
+ valid key and no endpoint, so every solve dialled a local port with nothing
67
+ behind it and failed with connection-refused — a message that says nothing
68
+ about the fact that their requests were meant to go to api.captchakraken.com.
69
+ """
70
+ return (
71
+ os.getenv("VLLM_BASE_URL")
72
+ or _base_url_from_credentials_file()
73
+ or f"http://localhost:{port()}/v1"
74
+ )
52
75
 
53
76
 
54
77
  def state_dir() -> Path:
@@ -60,38 +83,83 @@ def credentials_path() -> Path:
60
83
  return state_dir() / "credentials"
61
84
 
62
85
 
63
- def _key_from_credentials_file() -> str:
64
- """Key written by the MCP server's signup flow (or a future `login` command).
86
+ # Names the credentials file may use for each value. Two spellings each, because
87
+ # the file doubles as something a user can `source` — the VLLM_* names are the
88
+ # env vars this module already honours, and the CAPTCHA_KRAKEN_* names are what
89
+ # the product calls them. Accepting both costs nothing and spares anyone the
90
+ # discovery that they picked the wrong one.
91
+ _KEY_NAMES = ("CAPTCHA_KRAKEN_API_KEY", "VLLM_API_KEY")
92
+ _BASE_URL_NAMES = ("CAPTCHA_KRAKEN_BASE_URL", "VLLM_BASE_URL")
93
+
94
+
95
+ def _read_credentials_file() -> Dict[str, str]:
96
+ """Parse the file the MCP server's signup flow writes.
65
97
 
66
98
  Hosted-API onboarding writes the key to a 0600 file rather than returning it
67
99
  through the agent transcript, so it never lands in an LLM context window.
68
100
  Reading it here is what lets a solve authenticate with NO env vars set.
69
101
 
70
- Deliberately tolerant of a bare token, an env-file line, or surrounding
71
- quotes: the file is written by a *separate* tool, and a format mismatch here
72
- would surface as a baffling 401 rather than an obvious parse error.
73
- Returns "" whenever the file is missing, unreadable, or has no usable line.
102
+ Returns a mapping of recognised name -> value. The special key ``""`` holds a
103
+ BARE TOKEN — a file containing nothing but `ck_live_…`, which is what the
104
+ original single-value format was and what a user hand-writing this file is
105
+ most likely to produce. Dropping that would silently break every existing
106
+ credentials file, so it is still accepted and still means "the API key".
107
+
108
+ Deliberately tolerant of quoting and comments: the file is written by a
109
+ *separate* tool, and a format mismatch here surfaces as a baffling 401
110
+ rather than an obvious parse error. Returns {} whenever the file is missing
111
+ or unreadable — the self-hosted case, which must stay silent.
74
112
  """
75
113
  try:
76
114
  text = credentials_path().read_text(encoding="utf-8")
77
115
  except OSError:
78
- return ""
116
+ return {}
79
117
 
118
+ found: Dict[str, str] = {}
80
119
  for line in text.splitlines():
81
120
  line = line.strip()
82
121
  if not line or line.startswith("#"):
83
122
  continue
84
123
  if "=" in line:
85
124
  name, _, value = line.partition("=")
125
+ name = name.strip()
86
126
  # An env-file with unrelated keys must not yield a bogus token.
87
- if name.strip() not in {"CAPTCHA_KRAKEN_API_KEY", "VLLM_API_KEY"}:
127
+ if name not in _KEY_NAMES and name not in _BASE_URL_NAMES:
88
128
  continue
89
- line = value.strip()
90
- return line.strip("'\"")
129
+ found.setdefault(name, value.strip().strip("'\""))
130
+ elif "" not in found:
131
+ # A bare token. Only the first such line counts; later prose in a
132
+ # hand-edited file must not overwrite the credential.
133
+ found[""] = line.strip("'\"")
134
+
135
+ return found
136
+
91
137
 
138
+ def _first_present(values: Dict[str, str], names) -> str:
139
+ """First non-empty value among `names`, in the order given."""
140
+ for name in names:
141
+ value = values.get(name, "").strip()
142
+ if value:
143
+ return value
92
144
  return ""
93
145
 
94
146
 
147
+ def _key_from_credentials_file() -> str:
148
+ """The API key from the credentials file, or "" if there isn't one."""
149
+ values = _read_credentials_file()
150
+ return _first_present(values, _KEY_NAMES) or values.get("", "").strip()
151
+
152
+
153
+ def _base_url_from_credentials_file() -> str:
154
+ """The endpoint from the credentials file, or "" if it names none.
155
+
156
+ A bare-token file deliberately yields nothing here: it carries no endpoint,
157
+ and inventing the hosted one for it would silently redirect a self-hoster
158
+ who wrote a token into that file by hand.
159
+ """
160
+ return _first_present(_read_credentials_file(), _BASE_URL_NAMES)
161
+
162
+
95
163
  def api_key() -> str:
96
164
  """Bearer token, in precedence order.
97
165
 
@@ -108,24 +176,58 @@ def api_key() -> str:
108
176
 
109
177
 
110
178
  # ── Model identity (all overridable — the CLI itself is model-agnostic) ──────
179
+ #
180
+ # Precedence, for every value below:
181
+ #
182
+ # 1. The environment. An explicit override always wins — this is how you PIN
183
+ # to a specific model, and pinning is opt-in by design.
184
+ # 2. models.json's `latest` entry. The default moves forward with the
185
+ # registry, so "the default download" and "the default prompt" advance
186
+ # together and cannot get out of step: prompts.resolve() reads the SAME
187
+ # entry to decide which prompt generation to send.
188
+ # 3. pinned_model.json. The record of what this release was validated
189
+ # against, kept as the fallback so an older checkout or a hand-edited
190
+ # manifest still resolves.
191
+ #
192
+ # Order 2-before-3 is the behaviour change: `latest` leads. Today both name
193
+ # CaptchaKraken_v1.1, so nothing moves until a newer model is registered — at
194
+ # which point that IS the intent.
195
+ def _registry_default(field: str) -> Optional[str]:
196
+ """`field` from the registry's `latest` model entry, or None."""
197
+ try:
198
+ from . import prompts # local: prompts imports nothing from config
199
+
200
+ entry = prompts.registered_models().get(prompts.latest_model() or "")
201
+ value = (entry or {}).get(field)
202
+ return value if isinstance(value, str) else None
203
+ except Exception: # noqa: BLE001 — a broken registry falls back, never raises
204
+ return None
205
+
206
+
111
207
  def base_model() -> str:
112
- return os.getenv("CAPTCHA_BASE_MODEL", pinned()["base_model"])
208
+ return os.getenv("CAPTCHA_BASE_MODEL") or _registry_default("base_model") \
209
+ or pinned()["base_model"]
113
210
 
114
211
 
115
212
  def lora_adapter() -> str:
116
213
  """HF repo id or local path of the captcha adapter served on top of the base."""
117
- return os.getenv("CAPTCHA_LORA_ADAPTER", pinned()["lora_adapter"])
214
+ from . import prompts
215
+
216
+ return (os.getenv("CAPTCHA_LORA_ADAPTER") or prompts.latest_model()
217
+ or pinned()["lora_adapter"])
118
218
 
119
219
 
120
220
  def lora_revision() -> str:
121
221
  """Git revision of the adapter repo to serve — pinning this is what makes
122
222
  'the adapter this release was tested against' a reproducible statement."""
123
- return os.getenv("CAPTCHA_LORA_REVISION", pinned()["lora_revision"])
223
+ return os.getenv("CAPTCHA_LORA_REVISION") or _registry_default("lora_revision") \
224
+ or pinned()["lora_revision"]
124
225
 
125
226
 
126
227
  def lora_name() -> str:
127
228
  """The served LoRA name the client sends as the `model` field."""
128
- return os.getenv("CAPTCHA_LORA_NAME", pinned()["lora_name"])
229
+ return os.getenv("CAPTCHA_LORA_NAME") or _registry_default("lora_name") \
230
+ or pinned()["lora_name"]
129
231
 
130
232
 
131
233
  # ── Local vLLM server knobs ─────────────────────────────────────────────────