captchakraken 2.8.0 → 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.
@@ -18,7 +18,7 @@
18
18
  *
19
19
  * Camoufox's own `humanize` is a DIFFERENT, browser-level mechanism that
20
20
  * re-humanises every `mouse.move()` it is handed. Running both composes them —
21
- * measured 82.1s vs 13.4s on one geetest_v4_slide solve, because each of the 60
21
+ * measured 82.1s vs 13.4s on one a GeeTest v4 slider solve, because each of the 60
22
22
  * points below became its own humanised sub-trajectory. Drive with humanize off.
23
23
  */
24
24
  export type Point = [number, number];
@@ -19,7 +19,7 @@
19
19
  *
20
20
  * Camoufox's own `humanize` is a DIFFERENT, browser-level mechanism that
21
21
  * re-humanises every `mouse.move()` it is handed. Running both composes them —
22
- * measured 82.1s vs 13.4s on one geetest_v4_slide solve, because each of the 60
22
+ * measured 82.1s vs 13.4s on one a GeeTest v4 slider solve, because each of the 60
23
23
  * points below became its own humanised sub-trajectory. Drive with humanize off.
24
24
  */
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
package/dist/types.d.ts CHANGED
@@ -407,6 +407,18 @@ export interface CaptchaKrakenConfig {
407
407
  */
408
408
  keyframeWaitTimeoutMs?: number;
409
409
  keyframeWaitPollMs?: number;
410
+ /**
411
+ * Hard ceiling on a burst that never repeats a screen. The burst normally
412
+ * ends when the cycle closes; this bounds a continuous animation, which never
413
+ * closes one. Default 12000ms.
414
+ */
415
+ videoBurstMaxMs?: number;
416
+ /**
417
+ * Record while the still screenshot is being read, so a cycling board is
418
+ * known before its answer is acted on. Default on. Off restores the older
419
+ * shape, which learned the same thing two rounds later.
420
+ */
421
+ speculativeBurstEnabled?: boolean;
410
422
  /**
411
423
  * Extra wall clock (ms) granted ONCE, the first time a solve escalates to a
412
424
  * recording. NOT a looser `overallSolveTimeoutMs`.
@@ -420,8 +432,8 @@ export interface CaptchaKrakenConfig {
420
432
  * timeout — which reads as a slow model rather than as a budget with no room
421
433
  * for what the solver had just decided to do.
422
434
  *
423
- * Measured 2026-08-22, Tier 3 run 32596340560: hcaptcha_click_image_by_traits,
424
- * hcaptcha_connect_path and hcaptcha_grid_3x3_property each failed this port
435
+ * Measured 2026-08-22, a full driver-gate run: an hCaptcha click-by-trait board,
436
+ * an hCaptcha connect-the-path board and an hCaptcha 3x3 property grid each failed this port
425
437
  * with "Captcha solve timed out after 45000ms (attempt 6/6)" at 49-59s, and
426
438
  * solve in 14-20s on the rounds the still path answers them.
427
439
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "captchakraken",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "description": "Captcha solver for Playwright and Puppeteer. Finds the captcha, reads the puzzle with a fine-tuned Qwen3.5-9B vision model, and clicks, drags, slides or types through to a token — reCAPTCHA, hCaptcha, GeeTest, NetEase Yidun, Tencent, Lemin, Prosopo, Turnstile and distorted text. Hosted API or self-hosted.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -13,6 +13,7 @@
13
13
  "scripts": {
14
14
  "prebuild": "node scripts/copy-python.mjs",
15
15
  "build": "tsc",
16
+ "demo": "tsx examples/demoSites.ts",
16
17
  "typecheck": "tsc --noEmit -p tsconfig.json",
17
18
  "test": "tsc -p tsconfig.test.json && node --test .test-build/*.test.js",
18
19
  "prepublishOnly": "npm run build",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "captchakraken"
7
- version = "2.8.0"
7
+ version = "2.9.0"
8
8
  description = "Captcha-solving engine and CLI: OpenCV grid detection plus a fine-tuned Qwen3.5-9B vision model, for image grids, click and drag puzzles, sliders, distorted text and animated challenges. Hosted API or self-hosted on vLLM."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -107,4 +107,4 @@ __all__ = [
107
107
  # It said 2.6.0 while the wheel on PyPI said 2.6.1, so anyone gating on the
108
108
  # runtime attribute saw a version that had not been current for weeks.
109
109
  # tests/test_public_contract.py compares all three.
110
- __version__ = "2.8.0"
110
+ __version__ = "2.9.0"
@@ -542,6 +542,10 @@ def _handle_solve_animated() -> bool:
542
542
  # solve log rather than only inferable from the answer.
543
543
  "keyframes": paths,
544
544
  "keyframe_mode": kfset.mode,
545
+ # What the board LOOKS like, as distinct from how it was
546
+ # sliced. The driver gates its clicks on this; see
547
+ # KeyframeSet.steady_screens.
548
+ "steady_screens": kfset.steady_screens,
545
549
  "source_frames": len(imgs),
546
550
  }))
547
551
  except UnsupportedCaptchaError as e:
@@ -14,7 +14,7 @@ three directions at once —
14
14
  device farm, a model of their own users) was composing two of them. That is
15
15
  not hypothetical — camoufox's `humanize` juggler re-humanises every
16
16
  `mouse.move()` it is handed, and running both measured 82.1s against 13.4s
17
- on one geetest_v4_slide solve, because each of the 60 trajectory points
17
+ on one a GeeTest v4 slider solve, because each of the 60 trajectory points
18
18
  became its own humanised sub-trajectory.
19
19
  - a caller on their own infrastructure, against their own fixtures, is paying
20
20
  for texture nobody is measuring.
@@ -41,7 +41,7 @@ pointer", because that is what the next gesture starts from and what
41
41
  `SolveResult.final_mouse_position` reports.
42
42
 
43
43
  The TypeScript side is `js/src/humanize.ts`; the two are one design in two
44
- languages, and `tests/test_humanizer_parity.py` in CaptchaKrakenFinetune pins
44
+ languages, and `the training repo's humanizer parity tests` in the training repo pins
45
45
  that the mode names and the pause vocabulary have not drifted.
46
46
  """
47
47
 
@@ -1,8 +1,8 @@
1
1
  """Reduce a recorded captcha clip to the few frames the model is shown.
2
2
 
3
3
  VERBATIM PORT. Everything below the docstring is byte-identical to
4
- `src/video/keyframes.py` in the CaptchaKrakenFinetune repo, and
5
- `scripts/check_prompt_parity.py` there fails the build if the two ever diverge.
4
+ the training-side extractor in the training repo, and
5
+ `scripts/the parity gate` there fails the build if the two ever diverge.
6
6
 
7
7
  That is not tidiness, it is a correctness requirement. The model is trained on
8
8
  keyframes cut by that code and answers with a frame NUMBER indexing into them. If
@@ -41,7 +41,7 @@ DEFAULT_MAX_KEYFRAMES = 6
41
41
  # picture". A false "same" merges two distinct states, which loses an answer, so
42
42
  # this is the most consequential number in the file.
43
43
  #
44
- # MEASURED, not guessed. Over all 20 real clips in cleanSamples/test/raw, the
44
+ # MEASURED, not guessed. Over all 20 real clips in the sample corpus, the
45
45
  # consecutive-frame differences fall into two clusters with a 64x gap:
46
46
  #
47
47
  # same picture (compression + antialiasing) <= 0.000067 (0.067 per mille)
@@ -162,6 +162,15 @@ class KeyframeSet:
162
162
  # `keyframes`). Empty for the other modes. Lets a caller answer "which
163
163
  # keyframe was the clip showing at source frame i?" without re-diffing.
164
164
  frame_states: List[int] = field(default_factory=list)
165
+ # How many distinct steady screens the clip sits on; 0 if it is not that
166
+ # kind of clip. INDEPENDENT OF `mode`, and the difference is the whole
167
+ # point: `mode` says what the slicer could PROVE about recurrence from one
168
+ # burst, this says what the board looks like. A three-screen captcha whose
169
+ # loop is longer than the recording is `even` with `steady_screens == 3`.
170
+ # The live driver reads this to decide whether waiting for a named screen
171
+ # is meaningful; reading `mode` for that put the wait off on 100% of real
172
+ # animated captchas. See `steady_screens()`.
173
+ steady_screens: int = 0
165
174
 
166
175
  def __len__(self) -> int:
167
176
  return len(self.keyframes)
@@ -226,6 +235,7 @@ class KeyframeSet:
226
235
  return {
227
236
  "stem": stem,
228
237
  "mode": self.mode,
238
+ "steady_screens": self.steady_screens,
229
239
  "fps": self.fps,
230
240
  "source_frames": self.source_frames,
231
241
  "params": self.params.as_dict(),
@@ -440,14 +450,22 @@ def _distinct_indices(
440
450
  return sorted(kept)
441
451
 
442
452
 
443
- def _detect_cycle(
453
+ def _steady_screens(
444
454
  frames: Sequence[np.ndarray], params: KeyframeParams
445
- ) -> Optional[Tuple[List[int], List[int]]]:
446
- """`(state_medoid_indices, per_frame_state)` if the clip is a cycle of steady
447
- pictures, else None.
455
+ ) -> Optional[Tuple[List[Tuple[int, int]], List[int], List[int]]]:
456
+ """`(holds, state_medoid_indices, state_per_hold)` if the clip is a set of
457
+ steady held pictures, else None.
448
458
 
449
- `per_frame_state[i]` is the 0-based state of source frame i, or -1 for a
450
- transition frame that belongs to no hold.
459
+ Split out of `_detect_cycle` because the answer is wanted TWICE and the two
460
+ questions are not the same one:
461
+
462
+ - "is this a cycle?" — needs a state to be seen coming BACK, which takes
463
+ more than one pass through the loop.
464
+ - "does this board sit on a few stable screens?" — needs only one pass,
465
+ and is what the live driver has to know in order to decide whether
466
+ waiting for a particular screen is meaningful.
467
+
468
+ Answering only the first left the second unasked. See `steady_screens`.
451
469
  """
452
470
  n = len(frames)
453
471
  runs = _anchor_runs(frames, params.steady_ratio)
@@ -479,6 +497,55 @@ def _detect_cycle(
479
497
  if len(state_reps) < 1:
480
498
  return None
481
499
 
500
+ # NO `distinct_ratio` HERE, deliberately. The merge above already separated
501
+ # these states by more than `steady_ratio`, which is the measured noise
502
+ # floor — that is the whole of "are these different pictures".
503
+ #
504
+ # `distinct_ratio` (0.02) is twenty times coarser and exists to license
505
+ # `cycle`'s COLLAPSE to one frame per state, where being too generous throws
506
+ # away the middle of a clip. Applying it here instead answers a question
507
+ # nobody asked and answers it wrongly: measured on a live GeeTest svg burst,
508
+ # the three screens are separated by 0.0056 and 0.0050 — thin line art, a
509
+ # few glyph strokes change — so a 0.02 bar reports a board with three
510
+ # obvious screens as having none. keyframes.py's own comment already said
511
+ # so ("real state separations top out around 0.007, well under this") and
512
+ # read it as evidence the check was harmless.
513
+ return holds, state_reps, hold_state
514
+
515
+
516
+ def steady_screens(frames: Sequence[np.ndarray], params: KeyframeParams) -> int:
517
+ """How many distinct steady screens this clip sits on. 0 if it does not.
518
+
519
+ THE LIVE DRIVER'S QUESTION, and it is not `mode`. A board that advances
520
+ through three held pictures gives a burst that contains ONE pass, so
521
+ `_detect_cycle` cannot prove it recurs and the clip is sliced `even` — which
522
+ is right for slicing (dedup reaches the same stills) and was being read by
523
+ the driver as "nothing recurs, do not wait for a screen". Every real
524
+ geetest_v4_svg clip is exactly this shape: 2-3 holds covering 95-100% of the
525
+ burst, mutually distinct, and the page absolutely does come back around.
526
+
527
+ A one-way animation — the case the no-wait path exists for — is nearly all
528
+ transition, so it has no qualifying holds and this returns 0.
529
+ """
530
+ got = _steady_screens(frames, params)
531
+ return len(got[1]) if got else 0
532
+
533
+
534
+ def _detect_cycle(
535
+ frames: Sequence[np.ndarray], params: KeyframeParams
536
+ ) -> Optional[Tuple[List[int], List[int]]]:
537
+ """`(state_medoid_indices, per_frame_state)` if the clip is a cycle of steady
538
+ pictures, else None.
539
+
540
+ `per_frame_state[i]` is the 0-based state of source frame i, or -1 for a
541
+ transition frame that belongs to no hold.
542
+ """
543
+ n = len(frames)
544
+ got = _steady_screens(frames, params)
545
+ if got is None:
546
+ return None
547
+ holds, state_reps, hold_state = got
548
+
482
549
  # A cycle REVISITS. More holds than states means some picture came back, which
483
550
  # is what "the video is just a shift between N steady images" describes. Equal
484
551
  # counts mean every hold was a new picture — a one-way progression (a slow
@@ -497,8 +564,10 @@ def _detect_cycle(
497
564
  if len(holds) <= len(state_reps) and len(state_reps) > 1:
498
565
  return None
499
566
 
500
- # Every pair of states must be clearly different, or we are calling noise a
501
- # state and would hand the model two images of the same thing.
567
+ # Every pair of states must be clearly different before the clip is
568
+ # COLLAPSED to one frame per state — that collapse is what this threshold
569
+ # licenses, and being too generous with it discards the middle of a clip.
570
+ # `steady_screens` deliberately does not apply it; see `_steady_screens`.
502
571
  for a in range(len(state_reps)):
503
572
  for b in range(a + 1, len(state_reps)):
504
573
  if frame_diff_ratio(frames[state_reps[a]], frames[state_reps[b]]) < params.distinct_ratio:
@@ -538,7 +607,7 @@ def _drop_smeared(
538
607
  six eval clips, dropping a SCREEN. A page that never reaches the model is
539
608
  unanswerable whenever the target lived on it.
540
609
  * *Drop when some other still is within `distinct_ratio`* cut
541
- `hcaptcha_tile_flip_video` from 6 stills to 3, because two boards of that
610
+ an hCaptcha tile-flip animation from 6 stills to 3, because two boards of that
542
611
  puzzle differ by ONE TILE — well inside `distinct_ratio` — so a diff
543
612
  threshold cannot tell "the same picture again" from "the next board".
544
613
  EXPECTED_SLICING says it directly: fewer than 6 "means dedup merged two
@@ -625,6 +694,7 @@ def extract_keyframes(
625
694
  reps, per_frame = cycle
626
695
  mode = "static" if len(reps) == 1 else "cycle"
627
696
  return KeyframeSet(
697
+ steady_screens=len(reps),
628
698
  mode=mode,
629
699
  keyframes=[
630
700
  Keyframe(number=k + 1, source_index=idx, timestamp_ms=_ms(idx),
@@ -654,6 +724,9 @@ def extract_keyframes(
654
724
  # smears the first pass had just removed.
655
725
  indices = _drop_smeared(frames, indices, p)
656
726
  return KeyframeSet(
727
+ # Asked even though the cycle test refused: the two questions differ,
728
+ # and this is the one the driver needs answered.
729
+ steady_screens=steady_screens(frames, p),
657
730
  mode="even",
658
731
  keyframes=[
659
732
  Keyframe(number=k + 1, source_index=idx, timestamp_ms=_ms(idx),
@@ -679,7 +752,7 @@ def extract_keyframes_from_video(
679
752
  # <media_dir>/keyframes/<stem>/keyframes.json
680
753
  #
681
754
  # Derived, regenerable, and ADDITIVE — the clip stays canonical next to it. That
682
- # matters most under `cleanSamples/test/`, which is irreplaceable hand-labeled
755
+ # matters most under the held-out sample corpus, which is irreplaceable hand-labeled
683
756
  # data we never rewrite (CLAUDE.md § Data trees): extraction only ever creates a
684
757
  # new sibling directory there.
685
758
 
@@ -62,7 +62,7 @@
62
62
  " authorised holder can do: a licensed model has nothing to",
63
63
  " download however good your credentials are. `latest` may",
64
64
  " never be private either — an unpinned self-hoster would get",
65
- " a 401 instead of a model — and check_prompt_parity.py says so.",
65
+ " a 401 instead of a model — and the release parity gate says so.",
66
66
  "",
67
67
  " \"licensed\" — NOT downloadable by anyone. Reachable only through",
68
68
  " api.captchakraken.com, or by a holder of a licence we issue.",
@@ -73,8 +73,8 @@
73
73
  "place that decides what an unpinned client downloads and what prompts it",
74
74
  "sends. A second list of 'models you may not publish' kept somewhere else",
75
75
  "would be a list that drifts, and the drift is silent in the direction that",
76
- "costs the most. The finetune repo's publisher reads this field and REFUSES",
77
- "to upload a licensed model to the Hub; check_prompt_parity.py refuses to let",
76
+ "costs the most. The publisher reads this field and REFUSES",
77
+ "to upload a licensed model to the Hub; the release parity gate refuses to let",
78
78
  "`latest` be one, because an unpinned self-hoster would resolve to weights",
79
79
  "they cannot obtain.",
80
80
  "",
@@ -102,7 +102,7 @@
102
102
  "",
103
103
  "Each expert name must resolve — through `served_aliases` or as a repo id —",
104
104
  "to a REGISTERED entry declaring the same `prompt_version` and the same",
105
- "`pixel_budget` as the model that routes to it. check_prompt_parity.py",
105
+ "`pixel_budget` as the model that routes to it. The release parity gate",
106
106
  "enforces that. It is what makes aliasing four arms onto one entry safe",
107
107
  "rather than convenient: the four share a base, a schema and a band today",
108
108
  "(measured, not assumed — runs 20260906-0142xx all carry prompt generation",
@@ -132,7 +132,7 @@
132
132
  "min": 518400,
133
133
  "max": 518400
134
134
  },
135
- "note": "PUBLIC on the Hub under Source-Available License v1.1, and the first published generation-2 model. TRAINED with floor 448^2 / ceiling 720^2, but MEASURED best served at a flat 720^2 — floor == ceiling, so every image arrives at exactly one size and the band cannot drift. The band was chosen by sweeping the serving floor over 448/576/640/704/720/736/768/896/1024^2 and scoring each arm: the plateau is 704-736^2, and it falls off above 768^2, where a 400x580 portrait capture runs more than 40% past the ViT's 48x48 position grid. Do not \"just use the ViT's native grid\": 640^2 is where that grid is exactly saturated for a portrait capture, and it scores WORSE than 720^2 — mild position-embedding extrapolation plus real pixels beats staying inside the grid. Was served as `captcha-v12` while absent from this file, which resolved it to generation 1 prompts against generation-2 weights — the failure this file exists to prevent."
135
+ "note": "Generation 2, and the first published one. Trained over a range of input sizes but served at a flat 720^2: floor == ceiling, so every image arrives at exactly one size and the band cannot drift. The band was chosen by sweeping the serving floor and scoring each setting; the plateau is 704-736^2 and it falls off above 768^2, where a portrait capture runs well past the vision tower's position grid. Note that the vision tower's native grid is NOT the best setting — mild position-embedding extrapolation over real pixels beats staying inside the grid."
136
136
  },
137
137
  "CaptchaKraken/Twilight-v1.2-FP8": {
138
138
  "prompt_version": "2",
@@ -142,7 +142,7 @@
142
142
  "min": 518400,
143
143
  "max": 518400
144
144
  },
145
- "note": "CaptchaKraken-Lora-v1.2 merged into Qwen/Qwen3.5-9B at FP8-dynamic. The weights the hosted API answers with. Generation 2, same band as the adapter it is made from: a merge that resolved to a different prompt generation or a different pixel budget than its own LoRA would be the 2026-07-18 regression with extra steps."
145
+ "note": "CaptchaKraken-Lora-v1.2 merged into Qwen/Qwen3.5-9B at FP8-dynamic. Generation 2, same pixel band as the adapter it is made from — a merge that resolved to a different prompt generation or a different band than its own LoRA is exactly the mispairing this file exists to prevent."
146
146
  },
147
147
  "CaptchaKraken/Sunlight-v1.2-AWQ-4bit": {
148
148
  "prompt_version": "2",
@@ -160,7 +160,7 @@
160
160
  "base_model": "RedHatAI/Qwen3.5-9B-FP8-dynamic",
161
161
  "lora_revision": "main",
162
162
  "lora_name": "captcha",
163
- "note": "Currently served in production. PRIVATE on the Hub, so prompts.json can never be fetched for it — this entry is the only thing that maps it to a prompt generation."
163
+ "note": "Generation 1. Private on the Hub, so prompts.json cannot be fetched for it — this entry is the only thing that maps it to a prompt generation."
164
164
  },
165
165
  "CaptchaKraken/CaptchaKrakenV1_Lora": {
166
166
  "prompt_version": "1",
@@ -168,19 +168,19 @@
168
168
  "base_model": "RedHatAI/Qwen3.5-9B-FP8-dynamic",
169
169
  "lora_revision": "main",
170
170
  "lora_name": "captcha",
171
- "note": "Published before prompts.json existed — no such file in the repo, so it resolves from here."
171
+ "note": "Generation-1 LoRA on Qwen3.5-9B. Superseded by v1.2; kept registered so an existing pin keeps resolving to the prompts it was trained on."
172
172
  },
173
173
  "CaptchaKraken/Sunlight-AWQ-4bit": {
174
174
  "prompt_version": "1",
175
175
  "kind": "merged",
176
176
  "lora_name": "captcha",
177
- "note": "Ships its own prompts.json; this entry keeps it resolvable offline."
177
+ "note": "Generation 1 merged into the base model at AWQ 4-bit."
178
178
  },
179
179
  "CaptchaKraken/Twilight-FP8": {
180
180
  "prompt_version": "1",
181
181
  "kind": "merged",
182
182
  "lora_name": "captcha",
183
- "note": "Ships its own prompts.json; this entry keeps it resolvable offline."
183
+ "note": "Generation 1 merged into the base model at FP8-dynamic."
184
184
  },
185
185
  "CaptchaKraken/Abyss": {
186
186
  "availability": "licensed",
@@ -198,7 +198,7 @@
198
198
  "video": "abyss-video",
199
199
  "text": "abyss-text"
200
200
  },
201
- "note": "IN DEVELOPMENT. Never published, never downloadable — see `availability`. Registered before it exists so the licence gates are armed rather than retrofitted after the first artifact is sitting on a disk: the publisher, the parity gate and `captchakraken fetch` all read this entry, and a rule added the day it is first needed is a rule that was absent on the day it was first needed. Being nameable is not being downloadable: `availability` is what refuses the weights, and the two are independent on purpose — a licence holder DOES name this model, which is why it and each of its experts has a served_aliases entry. ABYSS IS THE ROUTED 9B, NOT THE 27B. It was going to be a single Qwen3.8-27B adapter; that model was trained (run 20260903-235101) and did not earn its cost, so the product became the four-expert mixture on Qwen3.5-9B that docs/MOE_LORA_DESIGN.md §7 and §10 specify. A routed 27B may be a later generation of this same name; it would be a new entry when it exists, because the base model is what a self-hoster loads. `lora_name` is `abyss-general` rather than a fifth name: the generalist IS the fallback for any family `experts` does not map, so pointing the two at one adapter is what makes §11’s “degrade to the generalist, never 400” true with one fewer resident LoRA slot — and slots are the binding constraint (§10, five names against `--max-loras`). prompt_version 2 and the flat 518400 band are what all four arms are training at TODAY (verified against training_runs/20260906-0142xx/run.json, min_pixels == max_pixels == 518400); CONFIRM BOTH from the run records at publish time and re-record here in the publishing commit, and if any arm has diverged give that arm its own entry — check_prompt_parity.py refuses the shared alias rather than resolving it silently."
201
+ "note": "A routed mixture of four expert LoRAs on Qwen3.5-9B — grid, pixel, video and text — selected by prompt family. Hosted only: `availability` refuses the weights, and being nameable is not being downloadable. A licence holder does name this model, which is why it and each of its arms has a served_aliases entry. `lora_name` is `abyss-general` rather than a fifth name because the generalist IS the fallback for any family `experts` does not map, so the two point at one adapter and the mixture needs one fewer resident LoRA slot."
202
202
  },
203
203
  "CaptchaKraken/Abyss-27B": {
204
204
  "availability": "private",
@@ -211,7 +211,7 @@
211
211
  "min": 518400,
212
212
  "max": 518400
213
213
  },
214
- "note": "PRIVATE on the Hub. The single-adapter 27B that Abyss started as — run 20260903-235101, re-gated as 20260906-025040. NOT the routed model and not one of its arms: it is a Qwen3.8-27B, and the base model is what a self-hoster loads, so it could never share the `CaptchaKraken/Abyss` entry however similar the product story is. Kept because it is measured and it wins: Tier 2 gated_overall 0.6699 against the v1.2 baseline's 0.6456, solved 0.7113, forgetting 0.0052. What it did not win was its COST — see the `CaptchaKraken/Abyss` note — which is a statement about serving economics, not about the weights, and is exactly why the weights are archived rather than deleted. Band and generation are the run's own (min_pixels == max_pixels == 518400, PROMPT_VERSION 2), read from training_runs/20260906-025040/model.json rather than assumed."
214
+ "note": "A single-adapter Qwen3.8-27B. NOT the routed Abyss and not one of its arms — the base model is what a self-hoster loads, so it could never share that entry. Archived rather than served."
215
215
  },
216
216
  "CaptchaKraken/Abyss-grid": {
217
217
  "availability": "private",
@@ -224,7 +224,7 @@
224
224
  "min": 518400,
225
225
  "max": 518400
226
226
  },
227
- "note": "PRIVATE on the Hub. The 'grid' expert arm of the routed CaptchaKraken/Abyss, from run 20260906-014318. Tier 2 gated_overall 0.7223 against a 0.6644 baseline, solved 0.6719, forgetting 0; Tier 3 9/10 pairs. Its run reads `blocked` for a TIER 1 failure that was this repo's own test harness (a module its sys.path insertion made unclassifiable, fixed in 79201f9aa) and not the model — the tier that judges the weights passed. It has its OWN entry rather than an alias onto the router because it now has its own weights at its own repo id, and `experts` names what goes ON THE WIRE — an alias would resolve a real, separately fetchable adapter to another model's entry. It still declares the router's prompt generation and pixel band, which is what check_prompt_parity.py requires of every expert and what makes the mixture coherent."
227
+ "note": "The 'grid' expert arm of the routed CaptchaKraken/Abyss. It has its own entry rather than an alias onto the router because it has its own weights at its own repo id, and `experts` names what goes on the wire. It declares the router's prompt generation and pixel band, which is what the parity gate requires of every arm and what makes the mixture coherent."
228
228
  },
229
229
  "CaptchaKraken/Abyss-text": {
230
230
  "availability": "private",
@@ -237,19 +237,45 @@
237
237
  "min": 518400,
238
238
  "max": 518400
239
239
  },
240
- "note": "PRIVATE on the Hub. The 'text' expert arm of the routed CaptchaKraken/Abyss, from run 20260906-014253. Tier 2 gated_overall 0.8750 against a 0.8375 baseline, solved 0.8750, forgetting 0; Tier 3 5/6 pairs. `passed-partial` because the run is type-narrowed by construction — an arm trains on its own families — which disqualifies `ready-to-deploy` and says nothing bad about it. It has its OWN entry rather than an alias onto the router because it now has its own weights at its own repo id, and `experts` names what goes ON THE WIRE — an alias would resolve a real, separately fetchable adapter to another model's entry. It still declares the router's prompt generation and pixel band, which is what check_prompt_parity.py requires of every expert and what makes the mixture coherent."
240
+ "note": "The 'text' expert arm of the routed CaptchaKraken/Abyss. Own entry, own weights, and it declares the router's prompt generation and pixel band — see CaptchaKraken/Abyss-grid."
241
+ },
242
+ "CaptchaKraken/Abyss-video": {
243
+ "availability": "private",
244
+ "prompt_version": "2",
245
+ "kind": "lora",
246
+ "base_model": "Qwen/Qwen3.5-9B",
247
+ "lora_revision": "main",
248
+ "lora_name": "abyss-video",
249
+ "pixel_budget": {
250
+ "min": 518400,
251
+ "max": 518400
252
+ },
253
+ "note": "The 'video' expert arm of the routed CaptchaKraken/Abyss. Own entry, own weights, and it declares the router's prompt generation and pixel band — see CaptchaKraken/Abyss-grid."
254
+ },
255
+ "CaptchaKraken/Abyss-general": {
256
+ "availability": "private",
257
+ "prompt_version": "2",
258
+ "kind": "lora",
259
+ "base_model": "Qwen/Qwen3.5-9B",
260
+ "lora_revision": "main",
261
+ "lora_name": "abyss-general",
262
+ "pixel_budget": {
263
+ "min": 518400,
264
+ "max": 518400
265
+ },
266
+ "note": "The 'pixel' expert arm of the routed CaptchaKraken/Abyss and its generalist fallback: the router's own `lora_name` names this entry. Own entry, own weights, and it declares the router's prompt generation and pixel band — see CaptchaKraken/Abyss-grid."
241
267
  }
242
268
  },
243
269
  "served_aliases": {
244
- "_comment": "A hosted endpoint serves a bare name, not a repo id. Map it back so prompt resolution works for callers who only ever see the alias. api.captchakraken.com now serves ONE model behind the permanent public name `captcha`, whatever the caller sends, so `captcha` means whichever adapter is current there — today CaptchaKraken-Lora-v1.2 — and NOT the v1.1 weights that share the name as a local vLLM alias. Self-hosters resolve by repo id via CAPTCHA_LORA_ADAPTER and are unaffected. Move this in the same change as the gateway's CKGATE_UPSTREAM_MODEL or the hosted model answers a prompt generation it was not trained on.",
270
+ "_comment": "A hosted endpoint serves a bare name, not a repo id. Map it back so prompt resolution works for callers who only ever see the alias. The hosted API serves one model behind the permanent public name `captcha`, whatever the caller sends, so `captcha` means whichever adapter is current there. Self-hosters resolve by repo id via CAPTCHA_LORA_ADAPTER and are unaffected. Move this in the same change as the gateway's upstream model, or the hosted model answers a prompt generation it was not trained on.",
245
271
  "captcha": "CaptchaKraken/CaptchaKraken-Lora-v1.2",
246
272
  "captcha-v12": "CaptchaKraken/CaptchaKraken-Lora-v1.2",
247
- "_comment_abyss": "`abyss` is here for the OPPOSITE reason to the two above, and it is not a contradiction of `availability: licensed`. An alias says which model a served NAME means; availability says whether its weights can be downloaded. A licence holder does name this model — the hosted API accepts it from a licensed key and lists it to them on /v1/models so their SDK can — and without this line `canonical_model_id('abyss')` is None, so their prompts resolve through the `latest` fallback: the exact model/generation mispairing this file exists to prevent, silent, and only accidentally right while `latest` happens to share the generation. It does NOT make the weights fetchable; `captchakraken fetch` reads availability and refuses this name, which is also the clear error a self-hoster who typed it deserves.",
273
+ "_comment_abyss": "An alias says which model a served NAME means; `availability` says whether its weights can be downloaded. The two are independent. A licence holder names this model — the hosted API accepts it from a licensed key and lists it on /v1/models — and without this line prompt resolution falls through to the `latest` fallback, which is the model/generation mispairing this file exists to prevent. It does not make the weights fetchable.",
248
274
  "abyss": "CaptchaKraken/Abyss",
249
- "_comment_abyss_experts": "The four expert arms. These are the names that go ON THE WIRE — a routed request for a grid names `abyss-grid`, never `abyss` — so without these lines `canonical_model_id` returns None for the string the client actually sent, and prompt resolution falls through to the `latest` fallback on every routed solve. That is the mispairing `abyss` itself is here to prevent, one level down and four times over. Aliasing four arms onto one entry rather than giving each its own is deliberate and CHECKED: they share a base, a prompt generation and a pixel band today, and check_prompt_parity.py requires every expert name to resolve to a registered entry declaring the SAME prompt_version and pixel_budget as the router — so the day an arm trains at another generation or another band, the gate refuses the alias and that arm has to graduate to an entry of its own.",
275
+ "_comment_abyss_experts": "The four expert arms. These are the names that go on the wire — a routed request for a grid names `abyss-grid`, never `abyss` — so without these lines prompt resolution falls through to `latest` on every routed solve. Aliasing the arms onto one entry is deliberate and checked: they share a base, a prompt generation and a pixel band, and the parity gate requires every expert name to resolve to a registered entry declaring the SAME prompt_version and pixel_budget as the router — so the day an arm trains at another generation or band, the gate refuses the alias and that arm has to graduate to an entry of its own.",
250
276
  "abyss-grid": "CaptchaKraken/Abyss-grid",
251
- "abyss-general": "CaptchaKraken/Abyss",
252
- "abyss-video": "CaptchaKraken/Abyss",
277
+ "abyss-general": "CaptchaKraken/Abyss-general",
278
+ "abyss-video": "CaptchaKraken/Abyss-video",
253
279
  "abyss-text": "CaptchaKraken/Abyss-text",
254
280
  "abyss-27b": "CaptchaKraken/Abyss-27B"
255
281
  }