captchakraken 2.8.0 → 3.0.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.
- package/AGENTS.md +112 -0
- package/NOTICE +36 -0
- package/dist/cli-invocation.d.ts +9 -40
- package/dist/cli-invocation.js +16 -49
- package/dist/errors.d.ts +7 -27
- package/dist/errors.js +6 -25
- package/dist/humanize.d.ts +56 -240
- package/dist/humanize.js +159 -415
- package/dist/index.d.ts +5 -129
- package/dist/index.js +4 -98
- package/dist/kinds.d.ts +135 -0
- package/dist/kinds.js +70 -0
- package/dist/limits.d.ts +0 -48
- package/dist/limits.js +2 -48
- package/dist/model-name.d.ts +4 -19
- package/dist/model-name.js +32 -40
- package/dist/playwright-types.d.ts +25 -95
- package/dist/playwright-types.js +0 -23
- package/dist/puppeteer-adapter.d.ts +4 -43
- package/dist/puppeteer-adapter.js +22 -58
- package/dist/python-command.d.ts +0 -41
- package/dist/python-command.js +0 -37
- package/dist/selectors.d.ts +54 -0
- package/dist/selectors.js +117 -0
- package/dist/slide-geometry.d.ts +2 -29
- package/dist/slide-geometry.js +18 -44
- package/dist/solver.d.ts +112 -539
- package/dist/solver.js +1088 -2950
- package/dist/timing.d.ts +7 -40
- package/dist/timing.js +4 -36
- package/dist/token-usage.d.ts +1 -7
- package/dist/token-usage.js +10 -95
- package/dist/types.d.ts +77 -483
- package/dist/watcher.d.ts +5 -78
- package/dist/watcher.js +0 -56
- package/package.json +12 -4
- package/python/AGENTS.md +122 -0
- package/python/NOTICE +36 -0
- package/python/README.md +1 -2
- package/python/examples/README.md +10 -0
- package/python/examples/_harness.py +4 -73
- package/python/examples/demoHcaptcha.py +0 -13
- package/python/examples/demoRecaptcha.py +0 -10
- package/python/examples/watch_playwright.py +1 -26
- package/python/examples/with_local_gguf.py +28 -0
- package/python/examples/with_playwright.py +0 -19
- package/python/pyproject.toml +68 -19
- package/python/src/captchakraken/__init__.py +37 -45
- package/python/src/captchakraken/action_types.py +14 -36
- package/python/src/captchakraken/cli.py +262 -879
- package/python/src/captchakraken/config.py +60 -146
- package/python/src/captchakraken/errors.py +17 -95
- package/python/src/captchakraken/humanize.py +174 -597
- package/python/src/captchakraken/image_processor.py +16 -235
- package/python/src/captchakraken/keyframes.py +69 -434
- package/python/src/captchakraken/kinds.py +152 -0
- package/python/src/captchakraken/models.json +79 -21
- package/python/src/captchakraken/overlay.py +65 -498
- package/python/src/captchakraken/page_solver.py +1123 -2564
- package/python/src/captchakraken/planner.py +70 -428
- package/python/src/captchakraken/prompts.py +45 -261
- package/python/src/captchakraken/selectors.py +184 -0
- package/python/src/captchakraken/server_manager.py +7 -58
- package/python/src/captchakraken/solver.py +128 -522
- package/python/src/captchakraken/timing.py +20 -74
- package/python/src/captchakraken/tool_calls/board_painted.py +47 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +20 -30
- package/python/src/captchakraken/tool_calls/find_grid.py +186 -1339
- package/python/src/captchakraken/tool_calls/track_piece.py +81 -57
- package/python/src/captchakraken/updater.py +7 -76
- package/python/src/captchakraken/watcher.py +12 -101
- package/scripts/copy-python.mjs +10 -10
- package/scripts/setup-python.js +1 -21
- package/dist/trajectory.d.ts +0 -58
- package/dist/trajectory.js +0 -266
- package/python/src/captchakraken/tool_calls/move_indicator.py +0 -431
- 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 finetune repo's `scripts/check_prompt_parity.py`, 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 finetune 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
|
-
#
|
|
57
|
-
#
|
|
58
|
-
# A PURE LITERAL, deliberately: the finetune repo's check_prompt_parity.py 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 finetune 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):
|
|
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
|
|
235
|
-
""
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
#
|
|
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 — `check_prompt_parity.py` 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]) ->
|
|
273
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
585
|
-
"""
|
|
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
|
|
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[
|
|
417
|
+
def route(model: Optional[str], family: Optional[PromptFamily], *,
|
|
614
418
|
pin: Optional[str] = None) -> str:
|
|
615
|
-
"""
|
|
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
|
-
|
|
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[
|
|
660
|
-
|
|
661
|
-
return
|
|
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:
|