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
@@ -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 Tier 2",
16
- "static-image gate against it, and update prompt_sha256 if the prompts moved."
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",
@@ -29,13 +29,13 @@ DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
29
29
  # The pixel floor the adapters are TRAINED at, and therefore the floor an image
30
30
  # has to clear before it is worth sending.
31
31
  #
32
- # Training exports `MIN_PIXELS=200704` (448², in the finetune repo's
33
- # scripts/train_unified.sh), so anything smaller is enlarged before the vision
32
+ # Training exports `MIN_PIXELS=200704` (448², in the training repo's
33
+ # the training pipeline), so anything smaller is enlarged before the vision
34
34
  # encoder sees it. Serving did none of that: vLLM runs with no
35
35
  # `--mm-processor-kwargs` and this client used to re-encode the file unchanged,
36
36
  # so a small captcha arrived at a geometry the model was never tuned on. It
37
37
  # fails as plausible-but-wrong coordinates, never as an error — measured on real
38
- # geetest_v3_slide captures (277x285), predictions landed 80-105 px from the
38
+ # a GeeTest v3 slider captures (277x285), predictions landed 80-105 px from the
39
39
  # hand label at native size and 1-4 px away once upscaled.
40
40
  #
41
41
  # A FLOOR, NOT A RESIZE. Images already above it are passed through byte-for-byte;
@@ -92,9 +92,9 @@ def _encode_image(path: str,
92
92
  im.save(buf, "PNG")
93
93
  return "image/png", base64.b64encode(buf.getvalue()).decode()
94
94
 
95
- # Header the fleet's haproxy front (on the reverse-proxy EC2) routes on. When a
95
+ # Header the fleet's the gateway front (on the reverse-proxy EC2) routes on. When a
96
96
  # caller sets CAPTCHA_REQUEST_PRIORITY to a positive int, every request carries
97
- # `X-JH-Priority: <n>`; haproxy sends anything above its threshold (5) straight
97
+ # `X-JH-Priority: <n>`; the gateway sends anything above its threshold (5) straight
98
98
  # to the backup GPUs so it never competes with production traffic on the main
99
99
  # 5090. The tier-2 CI gate sets this — its traffic is throwaway and must stay
100
100
  # off the primary.
@@ -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
  #
@@ -266,7 +274,7 @@ def video_action_prompt(n_keyframes: int) -> str:
266
274
 
267
275
  The count is in the text because the model has no other way to know how many
268
276
  images arrived — frame identities live in the prompt, not in the image payload.
269
- Mirrors `instructions.video_instruction` in the finetune repo exactly.
277
+ Mirrors `instructions.video_instruction` in the training repo exactly.
270
278
  """
271
279
  n = int(n_keyframes)
272
280
  if n < 1:
@@ -275,6 +283,66 @@ def video_action_prompt(n_keyframes: int) -> str:
275
283
  return VIDEO_ACTION_PROMPT_TEMPLATE.format(n=n, listing=listing)
276
284
 
277
285
 
286
+ #: Sampling temperature per RE-ASK of a board the vendor refused, first entry
287
+ #: first. THE ONE COPY: the JS port reaches this through `CAPTCHA_RESAMPLE_LEVEL`
288
+ #: rather than carrying its own numbers, because a schedule written down twice is
289
+ #: a schedule the two ports can disagree about — and the driver gate drives both
290
+ #: and averages them.
291
+ #:
292
+ #: GREEDY ONLY, AND THAT IS A MEASUREMENT, NOT AN OVERSIGHT. Heating the re-ask
293
+ #: was tried: `(0.0, 0.35, 0.7)`, escalating each time a board came back with the
294
+ #: answer it had already given. On five puzzle types that a greedy driver passes,
295
+ #: the escalating one scored 0/5 against 4/5, and the median FAILED attempt cost
296
+ #: 24.0s against 13.9s. Two things went wrong and they compound:
297
+ #:
298
+ #: 1. A hotter sample is a WORSE answer. The greedy read is the best single
299
+ #: guess this model has; the schedule spends rounds walking away from it.
300
+ #: 2. It defeats the give-up rule. `_no_progress_rounds` counts IDENTICAL
301
+ #: answers, so making each re-ask differ resets the counter — the attempt
302
+ #: stops bailing at round 3 and runs to the round ceiling instead. Slower
303
+ #: failures, not fewer.
304
+ #:
305
+ #: The plumbing stays because the LEVEL is the right thing to travel; what was
306
+ #: wrong was the trigger. A re-ask should be driven by the vendor actually
307
+ #: refusing the answer — read off the network response — and not by the model
308
+ #: agreeing with itself, which is only ever evidence that decoding is
309
+ #: deterministic. Add temperatures here when that signal exists to gate them.
310
+ RESAMPLE_TEMPERATURES = (0.0,)
311
+
312
+
313
+ def sampling_for_level(level: int) -> Dict[str, Any]:
314
+ """Decode overrides for the `level`-th re-ask of one board.
315
+
316
+ Level 0 is greedy and returns {}, so a first look is byte-identical to what
317
+ this client has always sent. Past that it carries a temperature AND a seed:
318
+ the temperature is what makes a second sample possible at all, and the seed
319
+ is what makes two re-asks at one temperature two samples rather than one
320
+ sample twice.
321
+ """
322
+ temps = RESAMPLE_TEMPERATURES or (0.0,)
323
+ temp = temps[min(max(level, 0), len(temps) - 1)]
324
+ if not temp:
325
+ return {}
326
+ return {"temperature": temp, "seed": 1000 + level}
327
+
328
+
329
+ def _sampling_from_env() -> Dict[str, Any]:
330
+ """The JS port's re-ask level, which arrives as an env var.
331
+
332
+ That port drives its own round loop and spawns this CLI once per round, so
333
+ a fresh process has no memory of how many times the board has already been
334
+ read. Passing the LEVEL rather than the temperature keeps the schedule in
335
+ one place — see `RESAMPLE_TEMPERATURES`.
336
+ """
337
+ raw = os.environ.get("CAPTCHA_RESAMPLE_LEVEL", "").strip()
338
+ if not raw:
339
+ return {}
340
+ try:
341
+ return sampling_for_level(int(raw))
342
+ except ValueError:
343
+ return {}
344
+
345
+
278
346
  class ActionPlanner:
279
347
  """Thin client for the vLLM `captcha` LoRA."""
280
348
 
@@ -284,6 +352,7 @@ class ActionPlanner:
284
352
  debug_callback: Optional[Any] = None,
285
353
  base_url: Optional[str] = None,
286
354
  api_key: Optional[str] = None,
355
+ expert: Optional[str] = None,
287
356
  **_: Any,
288
357
  ):
289
358
  self.debug_callback = debug_callback
@@ -294,6 +363,15 @@ class ActionPlanner:
294
363
  self.model = model or config.lora_name()
295
364
  self.base_url = base_url or config.base_url()
296
365
  self.api_key = api_key or config.api_key()
366
+ #: Decode overrides merged into every request payload, or empty.
367
+ #:
368
+ #: The page driver sets this while RE-ASKING a board the vendor already
369
+ #: refused, so that the second look is a second SAMPLE rather than the
370
+ #: same arithmetic run twice — see
371
+ #: `PageSolverConfig.resample_temperatures`. Empty by default and
372
+ #: cleared the moment a fresh board arrives, because greedy is the best
373
+ #: single guess and every solve that works today works on it.
374
+ self.sampling: Dict[str, Any] = {}
297
375
  # Prompts follow the MODEL, not this client's release. Resolve by the
298
376
  # served name first (a hosted endpoint serves `captcha`, not a repo id,
299
377
  # and models.json maps the alias back), falling back to the adapter repo
@@ -309,9 +387,102 @@ class ActionPlanner:
309
387
  # the same key: an adapter reads a puzzle at the pixel band it trained
310
388
  # under. Resolved once per planner — see the note above.
311
389
  self.pixel_budget = prompts.pixel_budget(_prompt_key)
390
+ # WHICH ADAPTER answers each prompt family. Empty for every model that
391
+ # is not routed, which is every model published so far — `_model_for`
392
+ # then returns `self.model` for every request and the wire is unchanged.
393
+ #
394
+ # Resolved once, like the two above, and for a stronger reason: the pin
395
+ # is validated here, so `--expert grid` against a single-adapter model
396
+ # fails when the solver is BUILT rather than on the first grid it meets.
397
+ self.expert = (expert if expert is not None
398
+ else prompts.expert_pin())
399
+ self.experts = prompts.experts(_prompt_key)
400
+ if self.expert:
401
+ # Raises on an unknown family, and on a pin against an unrouted
402
+ # model. Both are configuration errors, and a benchmark that
403
+ # quietly measured the generalist while reporting an expert is the
404
+ # exact class of silent mispairing models.json exists to prevent.
405
+ prompts.route(_prompt_key, None, pin=self.expert)
312
406
  # Auto-start a local vLLM server on the first request if one isn't up
313
407
  # (no-op for a healthy or remote endpoint). Guarded so we only try once.
314
408
  self._server_ensured = False
409
+ # ONE connection, reused for every round of every solve this planner
410
+ # serves. `requests.post` opens a fresh TCP connection each call — and
411
+ # to a hosted endpoint a TLS handshake with it. Measured against the
412
+ # gate's own endpoint: 258ms per request connectionless vs 144ms
413
+ # pooled, so every inference was paying ~110ms to re-dial a server it
414
+ # had just finished talking to. A multi-round grid pays it per round.
415
+ #
416
+ # The planner is per-PageSolver and a PageSolver is reusable across
417
+ # solves, so a caller who keeps one keeps the connection warm too.
418
+ self._http = requests.Session()
419
+ # Latched off by the first 404 — see `report_outcome`.
420
+ self._outcome_supported = True
421
+
422
+ def _model_for(self, family: Optional[str]) -> str:
423
+ """The `model` string for a request in `family`.
424
+
425
+ `self.model` whenever the model is not routed, so a caller may pass a
426
+ family unconditionally. A routed model with no expert for this family
427
+ degrades to the generalist — never to another expert, never to an
428
+ error (docs/MOE_LORA_DESIGN.md §11).
429
+ """
430
+ if not self.experts:
431
+ return self.model
432
+ return prompts.route(self.model, family, pin=self.expert) or self.model
433
+
434
+ # ── the solve's own verdict, sent back ─────────────────────────────────
435
+ #
436
+ # THE ONE THING THE SERVER CANNOT SEE. A wrong answer reaches the gateway as
437
+ # a 200 with well-formed JSON in it; only this driver watched the widget.
438
+ # Without this report the hosted API cannot tell a solved captcha from a
439
+ # failed one, and the failures — the exact boards the model is worst at, on
440
+ # real sites — are the one dataset that cannot be collected any other way.
441
+ #
442
+ # BEST-EFFORT, ALWAYS. It runs after the solve is over and its result is
443
+ # already decided, so nothing it does can change the answer the caller gets.
444
+ # Every failure is swallowed: a self-hosted vLLM has no such route, a
445
+ # network blip is not the caller's problem, and an exception here would turn
446
+ # a successful solve into a raised one.
447
+ #
448
+ # ONE 404 IS ENOUGH. A self-hosted endpoint answers 404 forever, so the
449
+ # first one latches this off for the life of the planner rather than paying
450
+ # a round trip per solve to be told the same thing again.
451
+ _OUTCOME_PATH = "/solve-outcome"
452
+ _OUTCOME_TIMEOUT_S = 3.0
453
+
454
+ def report_outcome(self, session_id: Optional[str], solved: bool) -> bool:
455
+ """Tell the API whether the widget accepted. True if it was delivered.
456
+
457
+ Never raises. The return value is for tests and for `--debug`; no caller
458
+ should branch on it.
459
+ """
460
+ if not session_id or not self._outcome_supported:
461
+ return False
462
+ if os.getenv(_REPORT_OUTCOME_ENV, "1") == "0":
463
+ return False
464
+ url = f"{self.base_url}{self._OUTCOME_PATH}"
465
+ try:
466
+ resp = self._http.post(
467
+ url,
468
+ headers={
469
+ "Authorization": f"Bearer {self.api_key}",
470
+ "Content-Type": "application/json",
471
+ },
472
+ json={"session": session_id, "solved": bool(solved)},
473
+ timeout=self._OUTCOME_TIMEOUT_S,
474
+ )
475
+ except Exception as exc: # noqa: BLE001 — see the note above
476
+ self._log(f"outcome report failed: {exc}")
477
+ return False
478
+ if resp.status_code == 404:
479
+ # Not an error: this endpoint does not serve the route. Stop asking.
480
+ self._outcome_supported = False
481
+ self._log("outcome reporting: endpoint has no /solve-outcome; disabled")
482
+ return False
483
+ ok = 200 <= resp.status_code < 300
484
+ self._log(f"outcome report {session_id} solved={solved} -> {resp.status_code}")
485
+ return ok
315
486
 
316
487
  def _log(self, message: str) -> None:
317
488
  if DEBUG:
@@ -319,11 +490,14 @@ class ActionPlanner:
319
490
  if self.debug_callback:
320
491
  self.debug_callback(f"[Planner] {message}")
321
492
 
322
- def _chat_with_image(self, prompt: str, image_path: str, max_tokens: int = 512) -> str:
323
- return self._chat_with_images(prompt, [image_path], max_tokens=max_tokens)
493
+ def _chat_with_image(self, prompt: str, image_path: str, max_tokens: int = 512,
494
+ family: Optional[str] = None) -> str:
495
+ return self._chat_with_images(prompt, [image_path], max_tokens=max_tokens,
496
+ family=family)
324
497
 
325
498
  def _chat_with_images(
326
- self, prompt: str, image_paths: List[str], max_tokens: int = 512
499
+ self, prompt: str, image_paths: List[str], max_tokens: int = 512,
500
+ family: Optional[str] = None,
327
501
  ) -> str:
328
502
  """One request carrying N images followed by the prompt.
329
503
 
@@ -355,8 +529,13 @@ class ActionPlanner:
355
529
  },
356
530
  ]
357
531
 
532
+ model = self._model_for(family)
533
+ # `sampling` is set by the page driver when it is RE-ASKING a board the
534
+ # vendor already refused — see `PageSolverConfig.resample_temperatures`.
535
+ # Absent (the normal case, and every first look) it is greedy exactly as
536
+ # before, so nothing about a solve that works today changes.
358
537
  payload = {
359
- "model": self.model,
538
+ "model": model,
360
539
  "messages": messages,
361
540
  "temperature": 0,
362
541
  "max_tokens": max_tokens,
@@ -365,13 +544,19 @@ class ActionPlanner:
365
544
  # level is the documented way.
366
545
  "chat_template_kwargs": {"enable_thinking": False},
367
546
  }
547
+ # In-process the driver sets `sampling` directly; the JS port drives
548
+ # its own loop and spawns this as a CLI, so its level arrives in the
549
+ # environment. Explicit beats inherited when both are present.
550
+ sampling = self.sampling or _sampling_from_env()
551
+ if sampling:
552
+ payload.update(sampling)
368
553
 
369
554
  headers = {
370
555
  "Authorization": f"Bearer {self.api_key}",
371
556
  "Content-Type": "application/json",
372
557
  # Fleet routing: absent unless CAPTCHA_REQUEST_PRIORITY is set (see
373
558
  # routing_headers). Low-priority batch traffic is steered to the
374
- # backup GPUs by the haproxy front on this header.
559
+ # backup GPUs by the gateway front on this header.
375
560
  **routing_headers(),
376
561
  }
377
562
 
@@ -387,11 +572,11 @@ class ActionPlanner:
387
572
  self._server_ensured = True
388
573
 
389
574
  url = f"{self.base_url}/chat/completions"
390
- self._log(f"POST {url} model={self.model} max_tokens={max_tokens} "
575
+ self._log(f"POST {url} model={model} max_tokens={max_tokens} "
391
576
  f"images={len(parts)}")
392
577
 
393
578
  with timed("planner.chat"):
394
- resp = requests.post(url, headers=headers, json=payload, timeout=120)
579
+ resp = self._http.post(url, headers=headers, json=payload, timeout=120)
395
580
 
396
581
  # Surface auth / billing / server errors as something the reader can act
397
582
  # on, instead of letting resp.json() blow up with a cryptic "Expecting
@@ -531,7 +716,8 @@ class ActionPlanner:
531
716
  "Return the complete list of cell numbers that match the description, "
532
717
  "including any matches you may have overlooked."
533
718
  )
534
- raw = self._chat_with_image(prompt, image_path, max_tokens=128)
719
+ raw = self._chat_with_image(prompt, image_path, max_tokens=128,
720
+ family="grid")
535
721
  out = self._normalize_grid(self._parse_json(raw), total)
536
722
  self._log(f"grid selection -> {out}")
537
723
  return out
@@ -592,7 +778,8 @@ class ActionPlanner:
592
778
  into ClickAction / DragAction / TypeAction.
593
779
  """
594
780
  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)
781
+ raw = self._chat_with_image(prompt, image_path, max_tokens=512,
782
+ family="text" if text_mode else "pixel")
596
783
  data = self._parse_json(raw)
597
784
  actions = self._normalize_pixel(data)
598
785
  self._log(f"pixel actions -> {actions}")
@@ -618,7 +805,8 @@ class ActionPlanner:
618
805
  # so this raises a clear error there instead of sending a v1 model a
619
806
  # keyframe request it was never trained to read.
620
807
  prompt = self.prompts.video_prompt(len(keyframe_paths))
621
- raw = self._chat_with_images(prompt, list(keyframe_paths), max_tokens=512)
808
+ raw = self._chat_with_images(prompt, list(keyframe_paths), max_tokens=512,
809
+ family="video")
622
810
  data = self._parse_json(raw)
623
811
  actions = self._normalize_pixel(data)
624
812
  frame = self._normalize_frame(data, len(keyframe_paths))
@@ -5,7 +5,7 @@ Why this exists
5
5
  The prompt a captcha model is sent has to be the prompt it was trained on. When
6
6
  they drift, nothing errors — the model answers in a schema the client does not
7
7
  recognise and puzzles silently fail. That has happened: see failure-mode 3 in
8
- the finetune repo's `scripts/check_prompt_parity.py`, where the shipped prompt
8
+ the training repo's release parity gate, where the shipped prompt
9
9
  kept asking for the legacy `output`/`simulate_drag`/PascalCase schema long after
10
10
  the LoRA had been retrained on `action: drag`/`drags[]`.
11
11
 
@@ -48,14 +48,14 @@ _PROMPTS_FILE_ENV = "CAPTCHA_PROMPTS_FILE"
48
48
  _DISABLE_FETCH_ENV = "CAPTCHA_PROMPTS_NO_FETCH"
49
49
 
50
50
  # The newest generation this client ships built-ins for. NOT "the version to
51
- # send" — that is per model, resolved below. This exists so the finetune repo
51
+ # send" — that is per model, resolved below. This exists so the training repo
52
52
  # can ask "do you speak the generation I am training?" without importing us.
53
53
  LATEST_PROMPT_VERSION = "2"
54
54
 
55
55
 
56
56
  # ── built-in templates, one entry per generation in service ─────────────────
57
57
  #
58
- # A PURE LITERAL, deliberately: the finetune repo's check_prompt_parity.py reads
58
+ # A PURE LITERAL, deliberately: the training repo's release parity gate reads
59
59
  # it by AST without importing this package (which pulls in pydantic/cv2 that the
60
60
  # training venv need not have). Keep it literal — an f-string, a .join(), or a
61
61
  # reference to another constant makes it unreadable to that gate, and the gate
@@ -68,7 +68,7 @@ LATEST_PROMPT_VERSION = "2"
68
68
  # 2 — adds the PUZZLE PIECE SLIDER clause and the animated + text families.
69
69
  # Byte-identical to src/synthetic/reasoning/instructions.py
70
70
  # ::PIXEL_INSTRUCTION_TEMPLATE, ::VIDEO_INSTRUCTION_TEMPLATE and
71
- # ::TEXT_INSTRUCTION in the finetune repo at PROMPT_VERSION 2.
71
+ # ::TEXT_INSTRUCTION in the training repo at PROMPT_VERSION 2.
72
72
  #
73
73
  # Do not edit a published generation's text. Ever. Those models are frozen and
74
74
  # so are their prompts; a change here is a change to what an already-shipped
@@ -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 — the release parity gate 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
- if is_healthy(base_url):
174
- return
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
- # Remote server we don't manage: let the actual request raise a clear
177
- # connection error rather than silently trying to boot vLLM locally.
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