captchakraken 2.3.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +47 -7
- package/README.md +24 -4
- package/dist/cli-invocation.d.ts +46 -0
- package/dist/cli-invocation.js +68 -0
- package/dist/errors.d.ts +45 -0
- package/dist/errors.js +74 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +52 -1
- package/dist/limits.d.ts +51 -0
- package/dist/limits.js +54 -0
- package/dist/model-name.d.ts +23 -0
- package/dist/model-name.js +70 -0
- package/dist/playwright-types.d.ts +28 -0
- package/dist/puppeteer-adapter.d.ts +11 -0
- package/dist/puppeteer-adapter.js +23 -0
- package/dist/python-command.d.ts +50 -0
- package/dist/python-command.js +60 -0
- package/dist/slide-geometry.d.ts +33 -0
- package/dist/slide-geometry.js +64 -0
- package/dist/solver.d.ts +214 -12
- package/dist/solver.js +1297 -165
- package/dist/trajectory.d.ts +31 -0
- package/dist/trajectory.js +162 -0
- package/dist/types.d.ts +128 -11
- package/dist/watcher.d.ts +99 -0
- package/dist/watcher.js +129 -0
- package/package.json +2 -5
- package/python/LICENSE +47 -7
- package/python/README.md +25 -8
- package/python/examples/README.md +36 -0
- package/python/examples/_harness.py +182 -94
- package/python/examples/demoHcaptcha.py +2 -1
- package/python/examples/demoRecaptcha.py +2 -1
- package/python/examples/watch_playwright.py +56 -0
- package/python/examples/with_playwright.py +43 -0
- package/python/pyproject.toml +4 -3
- package/python/src/captchakraken/__init__.py +5 -3
- package/python/src/captchakraken/action_types.py +24 -3
- package/python/src/captchakraken/cli.py +224 -2
- package/python/src/captchakraken/config.py +119 -17
- package/python/src/captchakraken/errors.py +195 -0
- package/python/src/captchakraken/keyframes.py +690 -0
- package/python/src/captchakraken/models.json +120 -0
- package/python/src/captchakraken/page_solver.py +1465 -102
- package/python/src/captchakraken/pinned_model.json +5 -7
- package/python/src/captchakraken/planner.py +328 -73
- package/python/src/captchakraken/prompts.py +485 -0
- package/python/src/captchakraken/solver.py +182 -11
- package/python/src/captchakraken/timing.py +70 -0
- package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
- package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
- package/python/src/captchakraken/trajectory.py +2 -7
- package/python/src/captchakraken/watcher.py +193 -0
- package/python/src/captchakraken/planner_types.py +0 -74
|
@@ -0,0 +1,485 @@
|
|
|
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
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import sys
|
|
42
|
+
from dataclasses import dataclass
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Any, Dict, Optional
|
|
45
|
+
|
|
46
|
+
_MODELS_PATH = Path(__file__).with_name("models.json")
|
|
47
|
+
_PROMPTS_FILE_ENV = "CAPTCHA_PROMPTS_FILE"
|
|
48
|
+
_DISABLE_FETCH_ENV = "CAPTCHA_PROMPTS_NO_FETCH"
|
|
49
|
+
|
|
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
|
+
LATEST_PROMPT_VERSION = "2"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── built-in templates, one entry per generation in service ─────────────────
|
|
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.
|
|
77
|
+
BUILTIN_PROMPTS = {
|
|
78
|
+
"1": {
|
|
79
|
+
"action_pixel": (
|
|
80
|
+
"Your task is to solve the captcha. Read the instruction at the top of the image carefully.\n\n"
|
|
81
|
+
"Look at the puzzle and decide what action solves it. All coordinates you return must be on a "
|
|
82
|
+
"normalized 0–1000 image scale (top-left = (0, 0), bottom-right = (1000, 1000)).\n\n"
|
|
83
|
+
"Name WHAT each object is with a short 1–2 word label, then give its position. "
|
|
84
|
+
"Choose ONE response:\n\n"
|
|
85
|
+
"FOR CLICK PUZZLES:\n"
|
|
86
|
+
" Label each thing you click and give its point — subjects[i] names points[i]:\n"
|
|
87
|
+
" → \"action\": \"click\", \"subjects\": [\"<label>\", ...], "
|
|
88
|
+
"\"points\": [[x1, y1], [x2, y2], ...]\n\n"
|
|
89
|
+
"FOR DRAG PUZZLES:\n"
|
|
90
|
+
" Drag ONE item at a time. Label the source (the piece you pick up) and the destination "
|
|
91
|
+
"(where it belongs), each with a short 1–2 word label, and give both points:\n"
|
|
92
|
+
" → \"action\": \"drag\", \"drags\": [{ \"source\": \"<label>\", \"from\": [x, y], "
|
|
93
|
+
"\"destination\": \"<label>\", \"to\": [x, y] }, ...]\n\n"
|
|
94
|
+
"Respond ONLY with JSON:\n"
|
|
95
|
+
"{\n"
|
|
96
|
+
" \"action\": \"click\", \"subjects\": [ ... ], \"points\": [ ... ]\n"
|
|
97
|
+
" // OR \"action\": \"drag\", \"drags\": [ ... ]\n"
|
|
98
|
+
"}"
|
|
99
|
+
),
|
|
100
|
+
"grid": (
|
|
101
|
+
"Solve the captcha grid by choosing the cell numbers that match the description "
|
|
102
|
+
"from the captcha image prompt.\n\nGrid: {rows}x{cols} ({total} cells)\n{grid_hint}\n\n"
|
|
103
|
+
"If no tiles match the description (e.g., they have all been cleared or none were "
|
|
104
|
+
"present), return an empty list for target_ids: [].\n\n"
|
|
105
|
+
"Return JSON Array: [list of cell numbers (1-{total})]"
|
|
106
|
+
),
|
|
107
|
+
"video": None,
|
|
108
|
+
"text": None,
|
|
109
|
+
},
|
|
110
|
+
"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
|
+
"action_pixel": (
|
|
118
|
+
"Your task is to solve the captcha. Read the instruction at the top of the image carefully.\n\n"
|
|
119
|
+
"Look at the puzzle and decide what action solves it. All coordinates you return must be on a "
|
|
120
|
+
"normalized 0–1000 image scale (top-left = (0, 0), bottom-right = (1000, 1000)).\n\n"
|
|
121
|
+
"Name WHAT each object is with a short 1–2 word label, then give its position. "
|
|
122
|
+
"Choose ONE response:\n\n"
|
|
123
|
+
"FOR CLICK PUZZLES:\n"
|
|
124
|
+
" Label each thing you click and give its point — subjects[i] names points[i]:\n"
|
|
125
|
+
" → \"action\": \"click\", \"subjects\": [\"<label>\", ...], "
|
|
126
|
+
"\"points\": [[x1, y1], [x2, y2], ...]\n\n"
|
|
127
|
+
"FOR DRAG PUZZLES:\n"
|
|
128
|
+
" Drag ONE item at a time. Label the source (the piece you pick up) and the destination "
|
|
129
|
+
"(where it belongs), each with a short 1–2 word label, and give both points:\n"
|
|
130
|
+
" → \"action\": \"drag\", \"drags\": [{ \"source\": \"<label>\", \"from\": [x, y], "
|
|
131
|
+
"\"destination\": \"<label>\", \"to\": [x, y] }, ...]\n\n"
|
|
132
|
+
"FOR PUZZLE PIECE SLIDER PUZZLES:\n"
|
|
133
|
+
" A single jigsaw piece has to end up in the piece-shaped slot cut into the picture. "
|
|
134
|
+
"Do not pick up the piece or the slider handle — leave the source EMPTY and give only "
|
|
135
|
+
"the destination, the CENTER OF THE SLOT the piece belongs in:\n"
|
|
136
|
+
" → \"action\": \"drag\", \"drags\": [{ \"source\": \"\", \"from\": [], "
|
|
137
|
+
"\"destination\": \"<label>\", \"to\": [x, y] }]\n\n"
|
|
138
|
+
"Respond ONLY with JSON:\n"
|
|
139
|
+
"{\n"
|
|
140
|
+
" \"action\": \"click\", \"subjects\": [ ... ], \"points\": [ ... ]\n"
|
|
141
|
+
" // OR \"action\": \"drag\", \"drags\": [ ... ]\n"
|
|
142
|
+
"}"
|
|
143
|
+
),
|
|
144
|
+
"grid": (
|
|
145
|
+
"Solve the captcha grid by choosing the cell numbers that match the description "
|
|
146
|
+
"from the captcha image prompt.\n\nGrid: {rows}x{cols} ({total} cells)\n{grid_hint}\n\n"
|
|
147
|
+
"A cell that is already selected — small checkmark badge, border or highlight — "
|
|
148
|
+
"still counts. Include it if it matches.\n\n"
|
|
149
|
+
"A cell being REPLACED does not: a large checkmark over the middle of the picture, "
|
|
150
|
+
"a picture fading to white, or a new picture fading in. That cell is on its way to "
|
|
151
|
+
"showing something else, so leave it out however well it matches.\n\n"
|
|
152
|
+
"If no cells match the description, return an empty list for target_ids: [].\n\n"
|
|
153
|
+
"Return JSON Array: [list of cell numbers (1-{total})]"
|
|
154
|
+
),
|
|
155
|
+
"video": (
|
|
156
|
+
"Your task is to solve the captcha. This challenge is animated, so instead of one "
|
|
157
|
+
"picture you are given {n} still keyframes cut from a short recording of it, in "
|
|
158
|
+
"order: {listing}.\n\n"
|
|
159
|
+
"Every keyframe shows the SAME puzzle at a different moment. Read the instruction at "
|
|
160
|
+
"the top of the keyframes carefully. What you need to act on may be visible in only "
|
|
161
|
+
"some of the frames — sprites fade in and out, boards cycle their contents — so pick "
|
|
162
|
+
"the ONE frame in which your target is clearest and report its number as \"frame\".\n\n"
|
|
163
|
+
"The frame number is there so the solver knows WHEN to press the mouse: it waits for the "
|
|
164
|
+
"widget to look like that frame before clicking. If your answer does not depend on the "
|
|
165
|
+
"frame — the target is in the same place in every one of them — then there is nothing to "
|
|
166
|
+
"wait for, and you may leave \"frame\" out entirely.\n\n"
|
|
167
|
+
"Read your coordinates off THAT frame. All coordinates must be on a normalized 0–1000 "
|
|
168
|
+
"image scale (top-left = (0, 0), bottom-right = (1000, 1000)).\n\n"
|
|
169
|
+
"Name WHAT each object is with a short 1–2 word label, then give its position. "
|
|
170
|
+
"Choose ONE response:\n\n"
|
|
171
|
+
"FOR CLICK PUZZLES:\n"
|
|
172
|
+
" Label each thing you click and give its point — subjects[i] names points[i]:\n"
|
|
173
|
+
" → \"frame\": <1-{n}>, \"action\": \"click\", \"subjects\": [\"<label>\", ...], "
|
|
174
|
+
"\"points\": [[x1, y1], [x2, y2], ...]\n\n"
|
|
175
|
+
"FOR DRAG PUZZLES:\n"
|
|
176
|
+
" Drag ONE item at a time. Label the source (the piece you pick up) and the "
|
|
177
|
+
"destination (where it belongs), each with a short 1–2 word label, and give both "
|
|
178
|
+
"points:\n"
|
|
179
|
+
" → \"frame\": <1-{n}>, \"action\": \"drag\", \"drags\": [{{ \"source\": \"<label>\", "
|
|
180
|
+
"\"from\": [x, y], \"destination\": \"<label>\", \"to\": [x, y] }}, ...]\n\n"
|
|
181
|
+
"Respond ONLY with JSON:\n"
|
|
182
|
+
"{{\n"
|
|
183
|
+
" \"frame\": <1-{n}>, // omit if your answer holds in every frame\n"
|
|
184
|
+
" \"action\": \"click\", \"subjects\": [ ... ], \"points\": [ ... ]\n"
|
|
185
|
+
" // OR \"action\": \"drag\", \"drags\": [ ... ]\n"
|
|
186
|
+
"}}"
|
|
187
|
+
),
|
|
188
|
+
"text": (
|
|
189
|
+
"Your task is to solve the captcha. The image shows a short code drawn in distorted, "
|
|
190
|
+
"overlapping or warped characters, sometimes over a busy background.\n\n"
|
|
191
|
+
"Read the code exactly as printed. Preserve letter case when the characters clearly "
|
|
192
|
+
"show it, and do not add spaces the image does not show. Decoration — strike-through "
|
|
193
|
+
"lines, dots, blobs, background texture — is not part of the code.\n\n"
|
|
194
|
+
"Respond ONLY with JSON:\n"
|
|
195
|
+
"{\n"
|
|
196
|
+
" \"action\": \"type\", \"text\": \"<the code>\"\n"
|
|
197
|
+
"}"
|
|
198
|
+
),
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ── the registry ────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
def _load_registry() -> Dict[str, Any]:
|
|
206
|
+
try:
|
|
207
|
+
with _MODELS_PATH.open(encoding="utf-8") as fh:
|
|
208
|
+
return json.load(fh)
|
|
209
|
+
except (OSError, ValueError): # a broken registry must not fail a solve
|
|
210
|
+
return {}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
_REGISTRY: Optional[Dict[str, Any]] = None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def registry() -> Dict[str, Any]:
|
|
217
|
+
global _REGISTRY
|
|
218
|
+
if _REGISTRY is None:
|
|
219
|
+
_REGISTRY = _load_registry()
|
|
220
|
+
return _REGISTRY
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def registered_models() -> Dict[str, Dict[str, Any]]:
|
|
224
|
+
"""Every model this client knows about, repo id -> entry."""
|
|
225
|
+
return {k: v for k, v in (registry().get("models") or {}).items()
|
|
226
|
+
if isinstance(v, dict)}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def latest_model() -> Optional[str]:
|
|
230
|
+
"""The model you get when you do not pin one."""
|
|
231
|
+
return registry().get("latest")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def canonical_model_id(model: Optional[str]) -> Optional[str]:
|
|
235
|
+
"""Repo id for `model`, resolving a served alias.
|
|
236
|
+
|
|
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
|
+
"""
|
|
242
|
+
if not model:
|
|
243
|
+
return None
|
|
244
|
+
if model in registered_models():
|
|
245
|
+
return model
|
|
246
|
+
alias = (registry().get("served_aliases") or {}).get(model)
|
|
247
|
+
return alias if isinstance(alias, str) else None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ── prompt sets ─────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
@dataclass
|
|
253
|
+
class PromptSet:
|
|
254
|
+
"""The prompts to send one particular model, and where they came from."""
|
|
255
|
+
|
|
256
|
+
version: str
|
|
257
|
+
action_prompt: str
|
|
258
|
+
grid_template: str
|
|
259
|
+
video_template: Optional[str]
|
|
260
|
+
text_template: Optional[str]
|
|
261
|
+
grid_by_type: Dict[str, str]
|
|
262
|
+
source: str
|
|
263
|
+
|
|
264
|
+
def grid_prompt(self, *, rows: int, cols: int, grid_hint: str = "",
|
|
265
|
+
puzzle_type: Optional[str] = None) -> str:
|
|
266
|
+
"""A published prompts.json carries grid prompts already rendered per
|
|
267
|
+
puzzle type; prefer that exact string when the type is known, since it
|
|
268
|
+
is literally what the model trained on. Otherwise render the template,
|
|
269
|
+
which is how this client has always built them."""
|
|
270
|
+
if puzzle_type and puzzle_type in self.grid_by_type:
|
|
271
|
+
return self.grid_by_type[puzzle_type]
|
|
272
|
+
return self.grid_template.format(rows=rows, cols=cols,
|
|
273
|
+
total=rows * cols, grid_hint=grid_hint)
|
|
274
|
+
|
|
275
|
+
def video_prompt(self, n_keyframes: int) -> str:
|
|
276
|
+
"""The prompt for a challenge served as `n_keyframes` stills.
|
|
277
|
+
|
|
278
|
+
The count is in the text because the model has no other way to know how
|
|
279
|
+
many images arrived — frame identities live in the prompt, not in the
|
|
280
|
+
image payload.
|
|
281
|
+
"""
|
|
282
|
+
n = int(n_keyframes)
|
|
283
|
+
if n < 1:
|
|
284
|
+
raise ValueError(f"a keyframe request needs at least one frame, got {n_keyframes}")
|
|
285
|
+
if self.video_template is None:
|
|
286
|
+
raise ValueError(
|
|
287
|
+
f"prompt generation {self.version} has no animated-puzzle prompt — "
|
|
288
|
+
f"model was trained before the video family existed. Use a model on "
|
|
289
|
+
f"generation 2 or later for animated captchas."
|
|
290
|
+
)
|
|
291
|
+
listing = ", ".join(f"frame {i}" for i in range(1, n + 1))
|
|
292
|
+
return self.video_template.format(n=n, listing=listing)
|
|
293
|
+
|
|
294
|
+
def text_prompt(self) -> str:
|
|
295
|
+
"""The prompt for a DISTORTED-TEXT captcha — one where the answer is a
|
|
296
|
+
string typed into the widget's box, not a point on the picture.
|
|
297
|
+
|
|
298
|
+
Asking for this when the generation has no such family is a hard error
|
|
299
|
+
rather than a silent fall back to the click/drag prompt: that prompt
|
|
300
|
+
asks for coordinates the puzzle has no use for, so a v1 model handed a
|
|
301
|
+
BotDetect image answers with a point, the driver clicks a random spot on
|
|
302
|
+
the letters, and the box stays empty. Failing loudly here names the
|
|
303
|
+
actual problem — the model predates the family.
|
|
304
|
+
"""
|
|
305
|
+
if self.text_template is None:
|
|
306
|
+
raise ValueError(
|
|
307
|
+
f"prompt generation {self.version} has no distorted-text prompt — "
|
|
308
|
+
f"model was trained before the text family existed. Use a model on "
|
|
309
|
+
f"generation 2 or later for BotDetect/MTCaptcha/Yandex text captchas."
|
|
310
|
+
)
|
|
311
|
+
return self.text_template
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def builtin(version: str) -> Optional[PromptSet]:
|
|
315
|
+
"""The built-in PromptSet for a generation, or None if we don't ship it."""
|
|
316
|
+
spec = BUILTIN_PROMPTS.get(str(version))
|
|
317
|
+
if spec is None:
|
|
318
|
+
return None
|
|
319
|
+
return PromptSet(
|
|
320
|
+
version=str(version),
|
|
321
|
+
action_prompt=spec["action_pixel"],
|
|
322
|
+
grid_template=spec["grid"],
|
|
323
|
+
video_template=spec["video"],
|
|
324
|
+
text_template=spec["text"],
|
|
325
|
+
grid_by_type={},
|
|
326
|
+
source=f"built-in v{version}",
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _from_doc(doc: Dict[str, Any], source: str) -> PromptSet:
|
|
331
|
+
"""A published prompts.json. Anything it omits falls back to the built-ins
|
|
332
|
+
for the version it declares — a partial file should narrow what we take from
|
|
333
|
+
it, not silently drop a whole prompt family."""
|
|
334
|
+
version = str(doc.get("prompt_version", LATEST_PROMPT_VERSION))
|
|
335
|
+
base = builtin(version) or builtin(LATEST_PROMPT_VERSION)
|
|
336
|
+
templates = doc.get("templates") or {}
|
|
337
|
+
grid_by_type = {k.split("grid::", 1)[1]: v for k, v in templates.items()
|
|
338
|
+
if k.startswith("grid::") and isinstance(v, str)}
|
|
339
|
+
return PromptSet(
|
|
340
|
+
version=version,
|
|
341
|
+
action_prompt=templates.get("action_pixel") or base.action_prompt,
|
|
342
|
+
grid_template=templates.get("grid") or base.grid_template,
|
|
343
|
+
video_template=templates.get("video") or base.video_template,
|
|
344
|
+
text_template=templates.get("text") or base.text_template,
|
|
345
|
+
grid_by_type=grid_by_type,
|
|
346
|
+
source=source,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
_cache: Dict[str, PromptSet] = {}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _warn(msg: str) -> None:
|
|
354
|
+
print(f"[captchakraken] {msg}", file=sys.stderr)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def resolve(model: Optional[str]) -> PromptSet:
|
|
358
|
+
"""The PromptSet for `model`. Never raises — see the module docstring for
|
|
359
|
+
the resolution order and why the registry comes before the Hub."""
|
|
360
|
+
key = model or ""
|
|
361
|
+
if key in _cache:
|
|
362
|
+
return _cache[key]
|
|
363
|
+
|
|
364
|
+
ps: Optional[PromptSet] = None
|
|
365
|
+
|
|
366
|
+
explicit = os.getenv(_PROMPTS_FILE_ENV)
|
|
367
|
+
if explicit:
|
|
368
|
+
try:
|
|
369
|
+
with open(explicit, encoding="utf-8") as fh:
|
|
370
|
+
ps = _from_doc(json.load(fh), f"file:{explicit}")
|
|
371
|
+
except Exception as exc: # noqa: BLE001 — never fail a solve over this
|
|
372
|
+
_warn(f"could not read {_PROMPTS_FILE_ENV}={explicit}: {exc}; "
|
|
373
|
+
"falling back to the registry")
|
|
374
|
+
|
|
375
|
+
repo_id = canonical_model_id(model)
|
|
376
|
+
if ps is None and repo_id:
|
|
377
|
+
entry = registered_models().get(repo_id) or {}
|
|
378
|
+
version = str(entry.get("prompt_version", "")) or None
|
|
379
|
+
if version:
|
|
380
|
+
ps = builtin(version)
|
|
381
|
+
if ps is None:
|
|
382
|
+
# Registered against a generation we ship no text for. The
|
|
383
|
+
# release gate exists to make this unreachable; if it happens
|
|
384
|
+
# anyway, say so loudly rather than guessing.
|
|
385
|
+
_warn(f"{repo_id} is registered as prompt_version {version}, which this "
|
|
386
|
+
f"client ships no prompts for — using v{LATEST_PROMPT_VERSION}. "
|
|
387
|
+
"Upgrade captchakraken.")
|
|
388
|
+
else:
|
|
389
|
+
ps = PromptSet(**{**ps.__dict__, "source": f"registry:{repo_id}"})
|
|
390
|
+
|
|
391
|
+
if ps is None and model and "/" in model and os.getenv(_DISABLE_FETCH_ENV, "0") != "1":
|
|
392
|
+
try:
|
|
393
|
+
from huggingface_hub import hf_hub_download # lazy: optional dependency
|
|
394
|
+
|
|
395
|
+
path = hf_hub_download(repo_id=model, filename="prompts.json")
|
|
396
|
+
with open(path, encoding="utf-8") as fh:
|
|
397
|
+
ps = _from_doc(json.load(fh), f"hub:{model}")
|
|
398
|
+
except Exception:
|
|
399
|
+
# No prompts.json, no hub, offline, or gated. All expected — the
|
|
400
|
+
# registry above is the supported path; this is a bonus for models
|
|
401
|
+
# published after this client shipped.
|
|
402
|
+
ps = None
|
|
403
|
+
|
|
404
|
+
if ps is None:
|
|
405
|
+
default_version = str(
|
|
406
|
+
(registered_models().get(latest_model() or "") or {})
|
|
407
|
+
.get("prompt_version", LATEST_PROMPT_VERSION))
|
|
408
|
+
ps = builtin(default_version) or builtin(LATEST_PROMPT_VERSION)
|
|
409
|
+
if model:
|
|
410
|
+
_warn(f"model {model!r} is not in models.json and published no prompts.json; "
|
|
411
|
+
f"using generation {ps.version}. If it was trained on different prompts, "
|
|
412
|
+
"its answers will be quietly worse — register it.")
|
|
413
|
+
|
|
414
|
+
_cache[key] = ps
|
|
415
|
+
return ps
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# ─── training pixel budget ──────────────────────────────────────────────────
|
|
419
|
+
# Same registry, same reason, different axis. See models.json's header comment:
|
|
420
|
+
# an adapter learns to read a puzzle at whatever MIN_PIXELS/MAX_PIXELS band its
|
|
421
|
+
# training exported, and serving it under a different band is the prompt bug
|
|
422
|
+
# wearing a different hat — silent, and wrong on every puzzle.
|
|
423
|
+
|
|
424
|
+
MIN_PIXELS_ENV = "CAPTCHA_MIN_PIXELS"
|
|
425
|
+
MAX_PIXELS_ENV = "CAPTCHA_MAX_PIXELS"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@dataclass(frozen=True)
|
|
429
|
+
class PixelBudget:
|
|
430
|
+
"""The image-area band a model was trained under, in pixels.
|
|
431
|
+
|
|
432
|
+
`minimum` is a floor: smaller images are upscaled to it. `maximum` is a
|
|
433
|
+
ceiling, or None for "send it as-is and let the server decide" — which is
|
|
434
|
+
what every model published before this field existed did.
|
|
435
|
+
"""
|
|
436
|
+
minimum: int
|
|
437
|
+
maximum: Optional[int]
|
|
438
|
+
source: str
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
#: What the client did before models.json carried a budget. 448² is Qwen's own
|
|
442
|
+
#: ViT floor; there was never a ceiling. Anything unregistered keeps this, so
|
|
443
|
+
#: adding the field cannot change an existing deployment.
|
|
444
|
+
DEFAULT_PIXEL_BUDGET = PixelBudget(minimum=448 * 448, maximum=None,
|
|
445
|
+
source="client-default")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _env_int(name: str) -> Optional[int]:
|
|
449
|
+
try:
|
|
450
|
+
value = int(os.environ.get(name) or 0)
|
|
451
|
+
except ValueError:
|
|
452
|
+
return None
|
|
453
|
+
return value if value > 0 else None
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def pixel_budget(model: Optional[str]) -> PixelBudget:
|
|
457
|
+
"""The budget to send `model` images at. Never raises.
|
|
458
|
+
|
|
459
|
+
Order mirrors `resolve`: an explicit env pin wins, then the registry, then
|
|
460
|
+
the historical default. There is deliberately no Hub fetch — a wrong budget
|
|
461
|
+
degrades quality silently, so it may only come from a source we can verify
|
|
462
|
+
offline.
|
|
463
|
+
"""
|
|
464
|
+
env_min, env_max = _env_int(MIN_PIXELS_ENV), _env_int(MAX_PIXELS_ENV)
|
|
465
|
+
if env_min or env_max:
|
|
466
|
+
return PixelBudget(
|
|
467
|
+
minimum=env_min or DEFAULT_PIXEL_BUDGET.minimum,
|
|
468
|
+
maximum=env_max if env_max else DEFAULT_PIXEL_BUDGET.maximum,
|
|
469
|
+
source="env")
|
|
470
|
+
|
|
471
|
+
repo_id = canonical_model_id(model)
|
|
472
|
+
entry = (registered_models().get(repo_id) or {}) if repo_id else {}
|
|
473
|
+
budget = entry.get("pixel_budget") or {}
|
|
474
|
+
if budget:
|
|
475
|
+
return PixelBudget(
|
|
476
|
+
minimum=int(budget.get("min") or DEFAULT_PIXEL_BUDGET.minimum),
|
|
477
|
+
maximum=int(budget["max"]) if budget.get("max") else None,
|
|
478
|
+
source=f"registry:{repo_id}")
|
|
479
|
+
return DEFAULT_PIXEL_BUDGET
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def clear_cache() -> None:
|
|
483
|
+
_cache.clear()
|
|
484
|
+
global _REGISTRY
|
|
485
|
+
_REGISTRY = None
|