captchakraken 2.4.0 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +47 -7
  2. package/README.md +60 -13
  3. package/dist/cli-invocation.d.ts +46 -0
  4. package/dist/cli-invocation.js +68 -0
  5. package/dist/index.d.ts +27 -0
  6. package/dist/index.js +29 -1
  7. package/dist/limits.d.ts +51 -0
  8. package/dist/limits.js +54 -0
  9. package/dist/model-name.d.ts +23 -0
  10. package/dist/model-name.js +70 -0
  11. package/dist/playwright-types.d.ts +28 -0
  12. package/dist/puppeteer-adapter.d.ts +11 -0
  13. package/dist/puppeteer-adapter.js +23 -0
  14. package/dist/python-command.d.ts +50 -0
  15. package/dist/python-command.js +60 -0
  16. package/dist/slide-geometry.d.ts +33 -0
  17. package/dist/slide-geometry.js +64 -0
  18. package/dist/solver.d.ts +217 -12
  19. package/dist/solver.js +1332 -172
  20. package/dist/trajectory.d.ts +31 -0
  21. package/dist/trajectory.js +162 -0
  22. package/dist/types.d.ts +158 -11
  23. package/dist/watcher.d.ts +99 -0
  24. package/dist/watcher.js +129 -0
  25. package/package.json +12 -7
  26. package/python/LICENSE +47 -7
  27. package/python/README.md +58 -13
  28. package/python/examples/README.md +36 -0
  29. package/python/examples/_harness.py +182 -94
  30. package/python/examples/demoHcaptcha.py +2 -1
  31. package/python/examples/demoRecaptcha.py +2 -1
  32. package/python/examples/watch_playwright.py +56 -0
  33. package/python/examples/with_playwright.py +43 -0
  34. package/python/pyproject.toml +24 -6
  35. package/python/src/captchakraken/__init__.py +5 -3
  36. package/python/src/captchakraken/action_types.py +24 -3
  37. package/python/src/captchakraken/cli.py +212 -2
  38. package/python/src/captchakraken/config.py +39 -5
  39. package/python/src/captchakraken/keyframes.py +690 -0
  40. package/python/src/captchakraken/models.json +120 -0
  41. package/python/src/captchakraken/page_solver.py +1559 -105
  42. package/python/src/captchakraken/pinned_model.json +5 -7
  43. package/python/src/captchakraken/planner.py +253 -59
  44. package/python/src/captchakraken/prompts.py +485 -0
  45. package/python/src/captchakraken/solver.py +182 -11
  46. package/python/src/captchakraken/timing.py +70 -0
  47. package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
  48. package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
  49. package/python/src/captchakraken/trajectory.py +2 -7
  50. package/python/src/captchakraken/watcher.py +193 -0
  51. 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