captchakraken 2.4.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 (51) 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/index.d.ts +27 -0
  6. package/dist/index.js +29 -1
  7. package/dist/limits.d.ts +51 -0
  8. package/dist/limits.js +54 -0
  9. package/dist/model-name.d.ts +23 -0
  10. package/dist/model-name.js +70 -0
  11. package/dist/playwright-types.d.ts +28 -0
  12. package/dist/puppeteer-adapter.d.ts +11 -0
  13. package/dist/puppeteer-adapter.js +23 -0
  14. package/dist/python-command.d.ts +50 -0
  15. package/dist/python-command.js +60 -0
  16. package/dist/slide-geometry.d.ts +33 -0
  17. package/dist/slide-geometry.js +64 -0
  18. package/dist/solver.d.ts +214 -12
  19. package/dist/solver.js +1285 -165
  20. package/dist/trajectory.d.ts +31 -0
  21. package/dist/trajectory.js +162 -0
  22. package/dist/types.d.ts +128 -11
  23. package/dist/watcher.d.ts +99 -0
  24. package/dist/watcher.js +129 -0
  25. package/package.json +2 -5
  26. package/python/LICENSE +47 -7
  27. package/python/README.md +25 -8
  28. package/python/examples/README.md +36 -0
  29. package/python/examples/_harness.py +182 -94
  30. package/python/examples/demoHcaptcha.py +2 -1
  31. package/python/examples/demoRecaptcha.py +2 -1
  32. package/python/examples/watch_playwright.py +56 -0
  33. package/python/examples/with_playwright.py +43 -0
  34. package/python/pyproject.toml +4 -3
  35. package/python/src/captchakraken/__init__.py +5 -3
  36. package/python/src/captchakraken/action_types.py +24 -3
  37. package/python/src/captchakraken/cli.py +212 -2
  38. package/python/src/captchakraken/config.py +39 -5
  39. package/python/src/captchakraken/keyframes.py +690 -0
  40. package/python/src/captchakraken/models.json +120 -0
  41. package/python/src/captchakraken/page_solver.py +1465 -102
  42. package/python/src/captchakraken/pinned_model.json +5 -7
  43. package/python/src/captchakraken/planner.py +253 -59
  44. package/python/src/captchakraken/prompts.py +485 -0
  45. package/python/src/captchakraken/solver.py +182 -11
  46. package/python/src/captchakraken/timing.py +70 -0
  47. package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
  48. package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
  49. package/python/src/captchakraken/trajectory.py +2 -7
  50. package/python/src/captchakraken/watcher.py +193 -0
  51. 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.4.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,6 +68,7 @@ import json
55
68
  import os
56
69
  import subprocess
57
70
  import sys
71
+ from typing import Optional
58
72
 
59
73
  from .errors import CaptchaKrakenAPIError
60
74
  from .solver import CaptchaSolver, UnsupportedCaptchaError
@@ -381,6 +395,8 @@ def _handle_serve() -> bool:
381
395
  grid-cell-states {a, b} -> states | {grid: null}
382
396
  grid-cell-states-fixed {a, b, grid_boxes} -> states
383
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}
384
400
 
385
401
  Unknown cmd / malformed line -> an {ok:false} response (the process keeps
386
402
  running). EOF on stdin ends the loop cleanly. All heavy detection delegates
@@ -414,6 +430,18 @@ def _handle_serve() -> bool:
414
430
  threshold = float(req.get("threshold", 0.005))
415
431
  moved = ImageProcessor.detect_movement(req["a"], req["b"], threshold)
416
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"))
417
445
  raise ValueError(f"unknown cmd: {cmd!r}")
418
446
 
419
447
  # Signal readiness so the JS side knows imports are done before it polls.
@@ -436,6 +464,172 @@ def _handle_serve() -> bool:
436
464
  return True
437
465
 
438
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
+
439
633
  def _handle_tool_commands() -> bool:
440
634
  if len(sys.argv) <= 1:
441
635
  return False
@@ -641,6 +835,12 @@ def main():
641
835
  return
642
836
  if _handle_tool_commands():
643
837
  return
838
+ if _handle_solve_animated():
839
+ return
840
+ if _handle_match_region():
841
+ return
842
+ if _handle_track_piece():
843
+ return
644
844
 
645
845
  parser = argparse.ArgumentParser(description="CaptchaKraken v2 (vLLM)")
646
846
  parser.add_argument("image_path", help="Path to the captcha image or video")
@@ -679,6 +879,15 @@ def main():
679
879
  "images'). Switches the grid prompt to a more aggressive variant that "
680
880
  "instructs the LoRA to look at the full grid for tiles it missed.",
681
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
+ )
682
891
 
683
892
  args = parser.parse_args()
684
893
 
@@ -693,6 +902,7 @@ def main():
693
902
  args.image_path,
694
903
  puzzle_source=args.puzzle_source,
695
904
  retry_mode=args.retry_mode,
905
+ text_mode=args.text_mode,
696
906
  )
697
907
 
698
908
  if isinstance(result, list):
@@ -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
 
@@ -176,24 +176,58 @@ def api_key() -> str:
176
176
 
177
177
 
178
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
+
179
207
  def base_model() -> str:
180
- 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"]
181
210
 
182
211
 
183
212
  def lora_adapter() -> str:
184
213
  """HF repo id or local path of the captcha adapter served on top of the base."""
185
- 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"])
186
218
 
187
219
 
188
220
  def lora_revision() -> str:
189
221
  """Git revision of the adapter repo to serve — pinning this is what makes
190
222
  'the adapter this release was tested against' a reproducible statement."""
191
- 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"]
192
225
 
193
226
 
194
227
  def lora_name() -> str:
195
228
  """The served LoRA name the client sends as the `model` field."""
196
- 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"]
197
231
 
198
232
 
199
233
  # ── Local vLLM server knobs ─────────────────────────────────────────────────