captchakraken 2.9.0 → 3.1.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 (77) hide show
  1. package/AGENTS.md +112 -0
  2. package/NOTICE +36 -0
  3. package/dist/cli-invocation.d.ts +7 -36
  4. package/dist/cli-invocation.js +8 -49
  5. package/dist/errors.d.ts +7 -27
  6. package/dist/errors.js +6 -25
  7. package/dist/humanize.d.ts +56 -240
  8. package/dist/humanize.js +159 -415
  9. package/dist/index.d.ts +5 -129
  10. package/dist/index.js +4 -98
  11. package/dist/kinds.d.ts +135 -0
  12. package/dist/kinds.js +70 -0
  13. package/dist/limits.d.ts +0 -48
  14. package/dist/limits.js +2 -48
  15. package/dist/model-name.d.ts +4 -19
  16. package/dist/model-name.js +32 -40
  17. package/dist/playwright-types.d.ts +25 -95
  18. package/dist/playwright-types.js +0 -23
  19. package/dist/puppeteer-adapter.d.ts +4 -43
  20. package/dist/puppeteer-adapter.js +22 -58
  21. package/dist/python-command.d.ts +0 -41
  22. package/dist/python-command.js +0 -37
  23. package/dist/selectors.d.ts +54 -0
  24. package/dist/selectors.js +117 -0
  25. package/dist/slide-geometry.d.ts +2 -29
  26. package/dist/slide-geometry.js +18 -44
  27. package/dist/solver.d.ts +131 -708
  28. package/dist/solver.js +1319 -3382
  29. package/dist/timing.d.ts +7 -40
  30. package/dist/timing.js +4 -36
  31. package/dist/token-usage.d.ts +1 -7
  32. package/dist/token-usage.js +10 -95
  33. package/dist/types.d.ts +77 -493
  34. package/dist/watcher.d.ts +5 -78
  35. package/dist/watcher.js +0 -56
  36. package/package.json +11 -4
  37. package/python/AGENTS.md +122 -0
  38. package/python/NOTICE +36 -0
  39. package/python/README.md +1 -2
  40. package/python/examples/README.md +10 -0
  41. package/python/examples/_harness.py +4 -73
  42. package/python/examples/demoHcaptcha.py +0 -13
  43. package/python/examples/demoRecaptcha.py +0 -10
  44. package/python/examples/watch_playwright.py +1 -26
  45. package/python/examples/with_local_gguf.py +28 -0
  46. package/python/examples/with_playwright.py +0 -19
  47. package/python/pyproject.toml +68 -19
  48. package/python/src/captchakraken/__init__.py +37 -45
  49. package/python/src/captchakraken/action_types.py +14 -36
  50. package/python/src/captchakraken/cli.py +262 -883
  51. package/python/src/captchakraken/config.py +60 -146
  52. package/python/src/captchakraken/errors.py +17 -95
  53. package/python/src/captchakraken/humanize.py +174 -597
  54. package/python/src/captchakraken/image_processor.py +16 -235
  55. package/python/src/captchakraken/keyframes.py +46 -484
  56. package/python/src/captchakraken/kinds.py +152 -0
  57. package/python/src/captchakraken/models.json +36 -4
  58. package/python/src/captchakraken/overlay.py +65 -498
  59. package/python/src/captchakraken/page_solver.py +1282 -3101
  60. package/python/src/captchakraken/planner.py +65 -487
  61. package/python/src/captchakraken/prompts.py +45 -261
  62. package/python/src/captchakraken/selectors.py +184 -0
  63. package/python/src/captchakraken/server_manager.py +7 -58
  64. package/python/src/captchakraken/solver.py +128 -522
  65. package/python/src/captchakraken/timing.py +20 -74
  66. package/python/src/captchakraken/tool_calls/board_painted.py +47 -0
  67. package/python/src/captchakraken/tool_calls/find_checkbox.py +20 -30
  68. package/python/src/captchakraken/tool_calls/find_grid.py +186 -1339
  69. package/python/src/captchakraken/tool_calls/track_piece.py +81 -57
  70. package/python/src/captchakraken/updater.py +7 -76
  71. package/python/src/captchakraken/watcher.py +12 -101
  72. package/scripts/copy-python.mjs +10 -10
  73. package/scripts/setup-python.js +1 -21
  74. package/dist/trajectory.d.ts +0 -58
  75. package/dist/trajectory.js +0 -266
  76. package/python/src/captchakraken/tool_calls/move_indicator.py +0 -431
  77. package/python/src/captchakraken/trajectory.py +0 -284
@@ -1,39 +1,3 @@
1
- """Prompt resolution: every model gets the prompts it was trained on.
2
-
3
- Why this exists
4
- ---------------
5
- The prompt a captcha model is sent has to be the prompt it was trained on. When
6
- they drift, nothing errors — the model answers in a schema the client does not
7
- recognise and puzzles silently fail. That has happened: see failure-mode 3 in
8
- the training repo's release parity gate, where the shipped prompt
9
- kept asking for the legacy `output`/`simulate_drag`/PascalCase schema long after
10
- the LoRA had been retrained on `action: drag`/`drags[]`.
11
-
12
- Hardcoding ONE generation of prompts makes that inevitable the moment a new
13
- model trains on different ones: updating the constants breaks every already-
14
- published model, and not updating them breaks the new one. There is no version
15
- of "one set of constants" that is correct.
16
-
17
- So this module ships the built-ins for EVERY generation still in service, and
18
- `models.json` records which generation each model was trained on. A model keeps
19
- working forever, whatever this client later ships.
20
-
21
- Resolution order
22
- ----------------
23
- 1. ``CAPTCHA_PROMPTS_FILE`` — explicit local path. Wins over everything; use it
24
- for a model you built yourself and have not published.
25
- 2. ``models.json`` — the registry shipped with this client. FIRST, not last,
26
- because it is the only source that works for a private model (the production
27
- adapter is gated on the Hub and 401s) or one published before prompts.json
28
- existed, and because it is the source the release gate actually verifies.
29
- 3. The served model's repo on the Hub, when it looks like ``org/name`` and
30
- ``huggingface_hub`` is importable — covers models published after this
31
- client was released.
32
- 4. The built-ins for whatever generation ``latest`` is on, with a warning.
33
-
34
- A prompt lookup must never fail a solve, so every step degrades rather than
35
- raises.
36
- """
37
1
  from __future__ import annotations
38
2
 
39
3
  import json
@@ -43,37 +7,17 @@ from dataclasses import dataclass
43
7
  from pathlib import Path
44
8
  from typing import Any, Dict, Optional
45
9
 
10
+ from .kinds import Availability, PromptFamily
11
+
46
12
  _MODELS_PATH = Path(__file__).with_name("models.json")
47
13
  _PROMPTS_FILE_ENV = "CAPTCHA_PROMPTS_FILE"
48
14
  _DISABLE_FETCH_ENV = "CAPTCHA_PROMPTS_NO_FETCH"
49
15
 
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 training repo
52
- # can ask "do you speak the generation I am training?" without importing us.
53
16
  LATEST_PROMPT_VERSION = "2"
54
17
 
55
18
 
56
- # ── built-in templates, one entry per generation in service ─────────────────
57
- #
58
- # A PURE LITERAL, deliberately: the training repo's release parity gate reads
59
- # it by AST without importing this package (which pulls in pydantic/cv2 that the
60
- # training venv need not have). Keep it literal — an f-string, a .join(), or a
61
- # reference to another constant makes it unreadable to that gate, and the gate
62
- # going quiet is how prompt drift got shipped last time.
63
- #
64
- # 1 — CaptchaKrakenV1_Lora, Sunlight-AWQ-4bit, Twilight-FP8, CaptchaKraken_v1.1.
65
- # 985-char action prompt. No slider clause. No animated ("video") family
66
- # and no distorted-text ("text") family: a v1 model cannot answer either
67
- # request, which is why both are null rather than a copy of v2's.
68
- # 2 — adds the PUZZLE PIECE SLIDER clause and the animated + text families.
69
- # Byte-identical to src/synthetic/reasoning/instructions.py
70
- # ::PIXEL_INSTRUCTION_TEMPLATE, ::VIDEO_INSTRUCTION_TEMPLATE and
71
- # ::TEXT_INSTRUCTION in the training repo at PROMPT_VERSION 2.
72
- #
73
- # Do not edit a published generation's text. Ever. Those models are frozen and
74
- # so are their prompts; a change here is a change to what an already-shipped
75
- # model is asked, which is the bug this whole module exists to prevent. New
76
- # text means a NEW generation.
19
+ # Pure literals only, no f-strings, joins or constant references: the training repo's release parity gate reads
20
+ # this dict by AST without importing the package, and the gate going quiet is how prompt drift shipped once.
77
21
  BUILTIN_PROMPTS = {
78
22
  "1": {
79
23
  "action_pixel": (
@@ -108,12 +52,6 @@ BUILTIN_PROMPTS = {
108
52
  "text": None,
109
53
  },
110
54
  "2": {
111
- # UNPUBLISHED as of 2026-08-03: no entry in models.json names generation
112
- # 2, `latest` is a generation-1 model, and no training run has produced
113
- # a deployable one. That is the ONLY reason its text may still move —
114
- # the freeze protects models that exist, and none exists here. The
115
- # moment a model registers against "2", this text is frozen with it and
116
- # a change means generation 3.
117
55
  "action_pixel": (
118
56
  "Your task is to solve the captcha. Read the instruction at the top of the image carefully.\n\n"
119
57
  "Look at the puzzle and decide what action solves it. All coordinates you return must be on a "
@@ -200,13 +138,11 @@ BUILTIN_PROMPTS = {
200
138
  }
201
139
 
202
140
 
203
- # ── the registry ────────────────────────────────────────────────────────────
204
-
205
141
  def _load_registry() -> Dict[str, Any]:
206
142
  try:
207
143
  with _MODELS_PATH.open(encoding="utf-8") as fh:
208
144
  return json.load(fh)
209
- except (OSError, ValueError): # a broken registry must not fail a solve
145
+ except (OSError, ValueError):
210
146
  return {}
211
147
 
212
148
 
@@ -221,24 +157,23 @@ def registry() -> Dict[str, Any]:
221
157
 
222
158
 
223
159
  def registered_models() -> Dict[str, Dict[str, Any]]:
224
- """Every model this client knows about, repo id -> entry."""
225
160
  return {k: v for k, v in (registry().get("models") or {}).items()
226
161
  if isinstance(v, dict)}
227
162
 
228
163
 
229
164
  def latest_model() -> Optional[str]:
230
- """The model you get when you do not pin one."""
231
165
  return registry().get("latest")
232
166
 
233
167
 
234
- def canonical_model_id(model: Optional[str]) -> Optional[str]:
235
- """Repo id for `model`, resolving a served alias.
168
+ def served_aliases() -> Dict[str, str]:
169
+ return dict(registry().get("served_aliases") or {})
170
+
171
+
172
+ def hosted_default_model() -> Optional[str]:
173
+ return registry().get("hosted_default")
174
+
236
175
 
237
- A hosted endpoint serves a BARE NAME (`captcha`), not a repo id, so the
238
- string the client sends as `model` is often not something the registry can
239
- be keyed by. Without this indirection every hosted solve would fall through
240
- to the default-version branch and lose the whole point of the map.
241
- """
176
+ def canonical_model_id(model: Optional[str]) -> Optional[str]:
242
177
  if not model:
243
178
  return None
244
179
  if model in registered_models():
@@ -247,85 +182,37 @@ def canonical_model_id(model: Optional[str]) -> Optional[str]:
247
182
  return alias if isinstance(alias, str) else None
248
183
 
249
184
 
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.
185
+ PUBLIC = Availability.PUBLIC
186
+ PRIVATE = Availability.PRIVATE
187
+ LICENSED = Availability.LICENSED
188
+ # String literals, like BUILTIN_PROMPTS: the parity gate reads this tuple by AST, and enum references are not literals to it.
269
189
  AVAILABILITIES = ("public", "private", "licensed")
270
190
 
271
191
 
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
- """
192
+ def availability(model: Optional[str]) -> Availability:
193
+ """Unset reads as public; an unrecognised value fails closed as licensed, never as a softer licence."""
296
194
  entry = registered_models().get(canonical_model_id(model) or "") or {}
297
195
  value = entry.get("availability")
298
- return value if isinstance(value, str) and value else PUBLIC
196
+ if not (isinstance(value, str) and value):
197
+ return Availability.PUBLIC
198
+ try:
199
+ return Availability(value)
200
+ except ValueError:
201
+ return Availability.LICENSED
299
202
 
300
203
 
301
204
  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)
205
+ # PRIVATE is not a softer LICENSED: refusing a licensed model early replaces a RepositoryNotFoundError that
206
+ # reads as "not logged in" with the truth, but refusing a private one would stop our own token pulling weights.
207
+ return availability(model) == Availability.LICENSED
312
208
 
313
209
 
314
210
  def requires_auth(model: Optional[str]) -> bool:
315
- """True if pulling these weights needs a token we authorised.
211
+ return availability(model) == Availability.PRIVATE
316
212
 
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
-
324
- # ── prompt sets ─────────────────────────────────────────────────────────────
325
213
 
326
214
  @dataclass
327
215
  class PromptSet:
328
- """The prompts to send one particular model, and where they came from."""
329
216
 
330
217
  version: str
331
218
  action_prompt: str
@@ -337,22 +224,12 @@ class PromptSet:
337
224
 
338
225
  def grid_prompt(self, *, rows: int, cols: int, grid_hint: str = "",
339
226
  puzzle_type: Optional[str] = None) -> str:
340
- """A published prompts.json carries grid prompts already rendered per
341
- puzzle type; prefer that exact string when the type is known, since it
342
- is literally what the model trained on. Otherwise render the template,
343
- which is how this client has always built them."""
344
227
  if puzzle_type and puzzle_type in self.grid_by_type:
345
228
  return self.grid_by_type[puzzle_type]
346
229
  return self.grid_template.format(rows=rows, cols=cols,
347
230
  total=rows * cols, grid_hint=grid_hint)
348
231
 
349
232
  def video_prompt(self, n_keyframes: int) -> str:
350
- """The prompt for a challenge served as `n_keyframes` stills.
351
-
352
- The count is in the text because the model has no other way to know how
353
- many images arrived — frame identities live in the prompt, not in the
354
- image payload.
355
- """
356
233
  n = int(n_keyframes)
357
234
  if n < 1:
358
235
  raise ValueError(f"a keyframe request needs at least one frame, got {n_keyframes}")
@@ -366,16 +243,6 @@ class PromptSet:
366
243
  return self.video_template.format(n=n, listing=listing)
367
244
 
368
245
  def text_prompt(self) -> str:
369
- """The prompt for a DISTORTED-TEXT captcha — one where the answer is a
370
- string typed into the widget's box, not a point on the picture.
371
-
372
- Asking for this when the generation has no such family is a hard error
373
- rather than a silent fall back to the click/drag prompt: that prompt
374
- asks for coordinates the puzzle has no use for, so a v1 model handed a
375
- BotDetect image answers with a point, the driver clicks a random spot on
376
- the letters, and the box stays empty. Failing loudly here names the
377
- actual problem — the model predates the family.
378
- """
379
246
  if self.text_template is None:
380
247
  raise ValueError(
381
248
  f"prompt generation {self.version} has no distorted-text prompt — "
@@ -386,7 +253,6 @@ class PromptSet:
386
253
 
387
254
 
388
255
  def builtin(version: str) -> Optional[PromptSet]:
389
- """The built-in PromptSet for a generation, or None if we don't ship it."""
390
256
  spec = BUILTIN_PROMPTS.get(str(version))
391
257
  if spec is None:
392
258
  return None
@@ -402,9 +268,6 @@ def builtin(version: str) -> Optional[PromptSet]:
402
268
 
403
269
 
404
270
  def _from_doc(doc: Dict[str, Any], source: str) -> PromptSet:
405
- """A published prompts.json. Anything it omits falls back to the built-ins
406
- for the version it declares — a partial file should narrow what we take from
407
- it, not silently drop a whole prompt family."""
408
271
  version = str(doc.get("prompt_version", LATEST_PROMPT_VERSION))
409
272
  base = builtin(version) or builtin(LATEST_PROMPT_VERSION)
410
273
  templates = doc.get("templates") or {}
@@ -429,8 +292,7 @@ def _warn(msg: str) -> None:
429
292
 
430
293
 
431
294
  def resolve(model: Optional[str]) -> PromptSet:
432
- """The PromptSet for `model`. Never raises — see the module docstring for
433
- the resolution order and why the registry comes before the Hub."""
295
+ """File, then registry, then the Hub: the registry is the only source that works for a private model (the production adapter is gated and 401s)."""
434
296
  key = model or ""
435
297
  if key in _cache:
436
298
  return _cache[key]
@@ -442,7 +304,7 @@ def resolve(model: Optional[str]) -> PromptSet:
442
304
  try:
443
305
  with open(explicit, encoding="utf-8") as fh:
444
306
  ps = _from_doc(json.load(fh), f"file:{explicit}")
445
- except Exception as exc: # noqa: BLE001 — never fail a solve over this
307
+ except Exception as exc:
446
308
  _warn(f"could not read {_PROMPTS_FILE_ENV}={explicit}: {exc}; "
447
309
  "falling back to the registry")
448
310
 
@@ -453,9 +315,6 @@ def resolve(model: Optional[str]) -> PromptSet:
453
315
  if version:
454
316
  ps = builtin(version)
455
317
  if ps is None:
456
- # Registered against a generation we ship no text for. The
457
- # release gate exists to make this unreachable; if it happens
458
- # anyway, say so loudly rather than guessing.
459
318
  _warn(f"{repo_id} is registered as prompt_version {version}, which this "
460
319
  f"client ships no prompts for — using v{LATEST_PROMPT_VERSION}. "
461
320
  "Upgrade captchakraken.")
@@ -464,15 +323,12 @@ def resolve(model: Optional[str]) -> PromptSet:
464
323
 
465
324
  if ps is None and model and "/" in model and os.getenv(_DISABLE_FETCH_ENV, "0") != "1":
466
325
  try:
467
- from huggingface_hub import hf_hub_download # lazy: optional dependency
326
+ from huggingface_hub import hf_hub_download
468
327
 
469
328
  path = hf_hub_download(repo_id=model, filename="prompts.json")
470
329
  with open(path, encoding="utf-8") as fh:
471
330
  ps = _from_doc(json.load(fh), f"hub:{model}")
472
331
  except Exception:
473
- # No prompts.json, no hub, offline, or gated. All expected — the
474
- # registry above is the supported path; this is a bonus for models
475
- # published after this client shipped.
476
332
  ps = None
477
333
 
478
334
  if ps is None:
@@ -489,32 +345,17 @@ def resolve(model: Optional[str]) -> PromptSet:
489
345
  return ps
490
346
 
491
347
 
492
- # ─── training pixel budget ──────────────────────────────────────────────────
493
- # Same registry, same reason, different axis. See models.json's header comment:
494
- # an adapter learns to read a puzzle at whatever MIN_PIXELS/MAX_PIXELS band its
495
- # training exported, and serving it under a different band is the prompt bug
496
- # wearing a different hat — silent, and wrong on every puzzle.
497
-
498
348
  MIN_PIXELS_ENV = "CAPTCHA_MIN_PIXELS"
499
349
  MAX_PIXELS_ENV = "CAPTCHA_MAX_PIXELS"
500
350
 
501
351
 
502
352
  @dataclass(frozen=True)
503
353
  class PixelBudget:
504
- """The image-area band a model was trained under, in pixels.
505
-
506
- `minimum` is a floor: smaller images are upscaled to it. `maximum` is a
507
- ceiling, or None for "send it as-is and let the server decide" — which is
508
- what every model published before this field existed did.
509
- """
510
354
  minimum: int
511
355
  maximum: Optional[int]
512
356
  source: str
513
357
 
514
358
 
515
- #: What the client did before models.json carried a budget. 448² is Qwen's own
516
- #: ViT floor; there was never a ceiling. Anything unregistered keeps this, so
517
- #: adding the field cannot change an existing deployment.
518
359
  DEFAULT_PIXEL_BUDGET = PixelBudget(minimum=448 * 448, maximum=None,
519
360
  source="client-default")
520
361
 
@@ -528,13 +369,7 @@ def _env_int(name: str) -> Optional[int]:
528
369
 
529
370
 
530
371
  def pixel_budget(model: Optional[str]) -> PixelBudget:
531
- """The budget to send `model` images at. Never raises.
532
-
533
- Order mirrors `resolve`: an explicit env pin wins, then the registry, then
534
- the historical default. There is deliberately no Hub fetch — a wrong budget
535
- degrades quality silently, so it may only come from a source we can verify
536
- offline.
537
- """
372
+ # Deliberately no Hub fetch here: a wrong budget degrades quality silently.
538
373
  env_min, env_max = _env_int(MIN_PIXELS_ENV), _env_int(MAX_PIXELS_ENV)
539
374
  if env_min or env_max:
540
375
  return PixelBudget(
@@ -553,52 +388,21 @@ def pixel_budget(model: Optional[str]) -> PixelBudget:
553
388
  return DEFAULT_PIXEL_BUDGET
554
389
 
555
390
 
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
391
  EXPERT_ENV = "CAPTCHA_EXPERT"
575
392
 
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.
393
+ # String literals: the parity gate reads this tuple by AST. The enum is PromptFamily.
581
394
  PROMPT_FAMILIES = ("pixel", "grid", "video", "text")
582
395
 
583
396
 
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
- """
397
+ def experts(model: Optional[str]) -> Dict[PromptFamily, str]:
398
+ """Unknown family keys are dropped with a warning, never raised: the day the `text` family reached ckgate without a marker, refusing it cost every distorted-text solve for a day."""
595
399
  repo_id = canonical_model_id(model)
596
400
  entry = (registered_models().get(repo_id) or {}) if repo_id else {}
597
401
  mapping = entry.get("experts")
598
402
  if not isinstance(mapping, dict):
599
403
  return {}
600
- out = {}
601
- for family in PROMPT_FAMILIES:
404
+ out: Dict[PromptFamily, str] = {}
405
+ for family in PromptFamily:
602
406
  name = mapping.get(family)
603
407
  if isinstance(name, str) and name:
604
408
  out[family] = name
@@ -610,32 +414,16 @@ def experts(model: Optional[str]) -> Dict[str, str]:
610
414
  return out
611
415
 
612
416
 
613
- def route(model: Optional[str], family: Optional[str], *,
417
+ def route(model: Optional[str], family: Optional[PromptFamily], *,
614
418
  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
- """
419
+ """An unknown pin raises (a benchmark that silently measured the generalist is a number nobody can catch); an unknown family degrades to the generalist."""
632
420
  if pin is not None:
633
421
  pin = pin.strip()
634
422
  if pin:
635
423
  if pin not in PROMPT_FAMILIES:
636
424
  raise ValueError(
637
425
  f"unknown expert {pin!r}; have {', '.join(PROMPT_FAMILIES)}")
638
- family = pin
426
+ family = PromptFamily(pin)
639
427
  mapping = experts(model)
640
428
  if not mapping:
641
429
  if pin:
@@ -648,17 +436,13 @@ def route(model: Optional[str], family: Optional[str], *,
648
436
 
649
437
 
650
438
  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."""
439
+ # The indirection matters: test_public_contract greps environ reads by name, and reading EXPERT_ENV inline would record the constant as a variable.
656
440
  return (os.environ.get(name) or "").strip() or None
657
441
 
658
442
 
659
- def expert_pin() -> Optional[str]:
660
- """`CAPTCHA_EXPERT`, or None. Empty and unset are the same thing."""
661
- return _env_str(EXPERT_ENV)
443
+ def expert_pin() -> Optional[PromptFamily]:
444
+ raw = _env_str(EXPERT_ENV)
445
+ return PromptFamily(raw) if raw else None
662
446
 
663
447
 
664
448
  def clear_cache() -> None: