captchakraken 2.6.1 → 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.
- package/README.md +90 -7
- package/dist/cli-invocation.d.ts +16 -16
- package/dist/cli-invocation.js +33 -21
- package/dist/errors.d.ts +1 -1
- package/dist/humanize.d.ts +377 -0
- package/dist/humanize.js +716 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +44 -1
- package/dist/model-name.d.ts +1 -1
- package/dist/model-name.js +1 -1
- package/dist/playwright-types.d.ts +18 -0
- package/dist/solver.d.ts +314 -16
- package/dist/solver.js +1208 -278
- package/dist/timing.d.ts +48 -0
- package/dist/timing.js +103 -0
- package/dist/trajectory.d.ts +28 -1
- package/dist/trajectory.js +105 -1
- package/dist/types.d.ts +113 -2
- package/package.json +2 -1
- package/python/README.md +43 -0
- package/python/pyproject.toml +1 -1
- package/python/src/captchakraken/__init__.py +36 -1
- package/python/src/captchakraken/cli.py +66 -4
- package/python/src/captchakraken/config.py +28 -2
- package/python/src/captchakraken/errors.py +24 -0
- package/python/src/captchakraken/humanize.py +929 -0
- package/python/src/captchakraken/keyframes.py +184 -12
- package/python/src/captchakraken/models.json +180 -18
- package/python/src/captchakraken/page_solver.py +1132 -321
- package/python/src/captchakraken/pinned_model.json +3 -2
- package/python/src/captchakraken/planner.py +204 -16
- package/python/src/captchakraken/prompts.py +186 -4
- package/python/src/captchakraken/server_manager.py +9 -4
- package/python/src/captchakraken/solver.py +98 -58
- package/python/src/captchakraken/timing.py +8 -3
- package/python/src/captchakraken/tool_calls/find_grid.py +357 -20
- package/python/src/captchakraken/trajectory.py +117 -0
- package/python/src/captchakraken/updater.py +62 -1
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
"serving prompt fails CI until someone consciously re-pins \u2014 which forces",
|
|
13
13
|
"the question 'does the pinned adapter still expect this prompt?'.",
|
|
14
14
|
"",
|
|
15
|
-
"To ship a new adapter: update lora_adapter/lora_revision, re-run the
|
|
16
|
-
"static-image gate against it, and update prompt_sha256 if the
|
|
15
|
+
"To ship a new adapter: update lora_adapter/lora_revision, re-run the",
|
|
16
|
+
"static-image accuracy gate against it, and update prompt_sha256 if the",
|
|
17
|
+
"prompts moved."
|
|
17
18
|
],
|
|
18
19
|
"base_model": "RedHatAI/Qwen3.5-9B-FP8-dynamic",
|
|
19
20
|
"lora_adapter": "CaptchaKraken/CaptchaKraken-Lora-v1.2",
|
|
@@ -29,13 +29,13 @@ DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
|
|
|
29
29
|
# The pixel floor the adapters are TRAINED at, and therefore the floor an image
|
|
30
30
|
# has to clear before it is worth sending.
|
|
31
31
|
#
|
|
32
|
-
# Training exports `MIN_PIXELS=200704` (448², in the
|
|
33
|
-
#
|
|
32
|
+
# Training exports `MIN_PIXELS=200704` (448², in the training repo's
|
|
33
|
+
# the training pipeline), so anything smaller is enlarged before the vision
|
|
34
34
|
# encoder sees it. Serving did none of that: vLLM runs with no
|
|
35
35
|
# `--mm-processor-kwargs` and this client used to re-encode the file unchanged,
|
|
36
36
|
# so a small captcha arrived at a geometry the model was never tuned on. It
|
|
37
37
|
# fails as plausible-but-wrong coordinates, never as an error — measured on real
|
|
38
|
-
#
|
|
38
|
+
# a GeeTest v3 slider captures (277x285), predictions landed 80-105 px from the
|
|
39
39
|
# hand label at native size and 1-4 px away once upscaled.
|
|
40
40
|
#
|
|
41
41
|
# A FLOOR, NOT A RESIZE. Images already above it are passed through byte-for-byte;
|
|
@@ -92,9 +92,9 @@ def _encode_image(path: str,
|
|
|
92
92
|
im.save(buf, "PNG")
|
|
93
93
|
return "image/png", base64.b64encode(buf.getvalue()).decode()
|
|
94
94
|
|
|
95
|
-
# Header the fleet's
|
|
95
|
+
# Header the fleet's the gateway front (on the reverse-proxy EC2) routes on. When a
|
|
96
96
|
# caller sets CAPTCHA_REQUEST_PRIORITY to a positive int, every request carries
|
|
97
|
-
# `X-JH-Priority: <n>`;
|
|
97
|
+
# `X-JH-Priority: <n>`; the gateway sends anything above its threshold (5) straight
|
|
98
98
|
# to the backup GPUs so it never competes with production traffic on the main
|
|
99
99
|
# 5090. The tier-2 CI gate sets this — its traffic is throwaway and must stay
|
|
100
100
|
# off the primary.
|
|
@@ -124,6 +124,14 @@ _CLIENT_ENV = "CAPTCHA_KRAKEN_CLIENT"
|
|
|
124
124
|
_SESSION_HEADER = "X-CK-Session"
|
|
125
125
|
_SESSION_ENV = "CAPTCHA_KRAKEN_SESSION"
|
|
126
126
|
|
|
127
|
+
# Report whether the widget accepted, once per solve. "0" turns it off.
|
|
128
|
+
#
|
|
129
|
+
# ON by default because the report is what makes a failed solve improvable, and
|
|
130
|
+
# because it is the only way a hosted account's failures can be found at all.
|
|
131
|
+
# Opting out here stops the CLIENT sending it; `captureOptOut` on the account is
|
|
132
|
+
# the server-side half, and either one alone is enough to keep nothing.
|
|
133
|
+
_REPORT_OUTCOME_ENV = "CAPTCHA_REPORT_OUTCOME"
|
|
134
|
+
|
|
127
135
|
# Arbitrary extra headers, as "Name: value" pairs separated by newlines or
|
|
128
136
|
# commas. Empty and absent by default.
|
|
129
137
|
#
|
|
@@ -266,7 +274,7 @@ def video_action_prompt(n_keyframes: int) -> str:
|
|
|
266
274
|
|
|
267
275
|
The count is in the text because the model has no other way to know how many
|
|
268
276
|
images arrived — frame identities live in the prompt, not in the image payload.
|
|
269
|
-
Mirrors `instructions.video_instruction` in the
|
|
277
|
+
Mirrors `instructions.video_instruction` in the training repo exactly.
|
|
270
278
|
"""
|
|
271
279
|
n = int(n_keyframes)
|
|
272
280
|
if n < 1:
|
|
@@ -275,6 +283,66 @@ def video_action_prompt(n_keyframes: int) -> str:
|
|
|
275
283
|
return VIDEO_ACTION_PROMPT_TEMPLATE.format(n=n, listing=listing)
|
|
276
284
|
|
|
277
285
|
|
|
286
|
+
#: Sampling temperature per RE-ASK of a board the vendor refused, first entry
|
|
287
|
+
#: first. THE ONE COPY: the JS port reaches this through `CAPTCHA_RESAMPLE_LEVEL`
|
|
288
|
+
#: rather than carrying its own numbers, because a schedule written down twice is
|
|
289
|
+
#: a schedule the two ports can disagree about — and the driver gate drives both
|
|
290
|
+
#: and averages them.
|
|
291
|
+
#:
|
|
292
|
+
#: GREEDY ONLY, AND THAT IS A MEASUREMENT, NOT AN OVERSIGHT. Heating the re-ask
|
|
293
|
+
#: was tried: `(0.0, 0.35, 0.7)`, escalating each time a board came back with the
|
|
294
|
+
#: answer it had already given. On five puzzle types that a greedy driver passes,
|
|
295
|
+
#: the escalating one scored 0/5 against 4/5, and the median FAILED attempt cost
|
|
296
|
+
#: 24.0s against 13.9s. Two things went wrong and they compound:
|
|
297
|
+
#:
|
|
298
|
+
#: 1. A hotter sample is a WORSE answer. The greedy read is the best single
|
|
299
|
+
#: guess this model has; the schedule spends rounds walking away from it.
|
|
300
|
+
#: 2. It defeats the give-up rule. `_no_progress_rounds` counts IDENTICAL
|
|
301
|
+
#: answers, so making each re-ask differ resets the counter — the attempt
|
|
302
|
+
#: stops bailing at round 3 and runs to the round ceiling instead. Slower
|
|
303
|
+
#: failures, not fewer.
|
|
304
|
+
#:
|
|
305
|
+
#: The plumbing stays because the LEVEL is the right thing to travel; what was
|
|
306
|
+
#: wrong was the trigger. A re-ask should be driven by the vendor actually
|
|
307
|
+
#: refusing the answer — read off the network response — and not by the model
|
|
308
|
+
#: agreeing with itself, which is only ever evidence that decoding is
|
|
309
|
+
#: deterministic. Add temperatures here when that signal exists to gate them.
|
|
310
|
+
RESAMPLE_TEMPERATURES = (0.0,)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def sampling_for_level(level: int) -> Dict[str, Any]:
|
|
314
|
+
"""Decode overrides for the `level`-th re-ask of one board.
|
|
315
|
+
|
|
316
|
+
Level 0 is greedy and returns {}, so a first look is byte-identical to what
|
|
317
|
+
this client has always sent. Past that it carries a temperature AND a seed:
|
|
318
|
+
the temperature is what makes a second sample possible at all, and the seed
|
|
319
|
+
is what makes two re-asks at one temperature two samples rather than one
|
|
320
|
+
sample twice.
|
|
321
|
+
"""
|
|
322
|
+
temps = RESAMPLE_TEMPERATURES or (0.0,)
|
|
323
|
+
temp = temps[min(max(level, 0), len(temps) - 1)]
|
|
324
|
+
if not temp:
|
|
325
|
+
return {}
|
|
326
|
+
return {"temperature": temp, "seed": 1000 + level}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _sampling_from_env() -> Dict[str, Any]:
|
|
330
|
+
"""The JS port's re-ask level, which arrives as an env var.
|
|
331
|
+
|
|
332
|
+
That port drives its own round loop and spawns this CLI once per round, so
|
|
333
|
+
a fresh process has no memory of how many times the board has already been
|
|
334
|
+
read. Passing the LEVEL rather than the temperature keeps the schedule in
|
|
335
|
+
one place — see `RESAMPLE_TEMPERATURES`.
|
|
336
|
+
"""
|
|
337
|
+
raw = os.environ.get("CAPTCHA_RESAMPLE_LEVEL", "").strip()
|
|
338
|
+
if not raw:
|
|
339
|
+
return {}
|
|
340
|
+
try:
|
|
341
|
+
return sampling_for_level(int(raw))
|
|
342
|
+
except ValueError:
|
|
343
|
+
return {}
|
|
344
|
+
|
|
345
|
+
|
|
278
346
|
class ActionPlanner:
|
|
279
347
|
"""Thin client for the vLLM `captcha` LoRA."""
|
|
280
348
|
|
|
@@ -284,6 +352,7 @@ class ActionPlanner:
|
|
|
284
352
|
debug_callback: Optional[Any] = None,
|
|
285
353
|
base_url: Optional[str] = None,
|
|
286
354
|
api_key: Optional[str] = None,
|
|
355
|
+
expert: Optional[str] = None,
|
|
287
356
|
**_: Any,
|
|
288
357
|
):
|
|
289
358
|
self.debug_callback = debug_callback
|
|
@@ -294,6 +363,15 @@ class ActionPlanner:
|
|
|
294
363
|
self.model = model or config.lora_name()
|
|
295
364
|
self.base_url = base_url or config.base_url()
|
|
296
365
|
self.api_key = api_key or config.api_key()
|
|
366
|
+
#: Decode overrides merged into every request payload, or empty.
|
|
367
|
+
#:
|
|
368
|
+
#: The page driver sets this while RE-ASKING a board the vendor already
|
|
369
|
+
#: refused, so that the second look is a second SAMPLE rather than the
|
|
370
|
+
#: same arithmetic run twice — see
|
|
371
|
+
#: `PageSolverConfig.resample_temperatures`. Empty by default and
|
|
372
|
+
#: cleared the moment a fresh board arrives, because greedy is the best
|
|
373
|
+
#: single guess and every solve that works today works on it.
|
|
374
|
+
self.sampling: Dict[str, Any] = {}
|
|
297
375
|
# Prompts follow the MODEL, not this client's release. Resolve by the
|
|
298
376
|
# served name first (a hosted endpoint serves `captcha`, not a repo id,
|
|
299
377
|
# and models.json maps the alias back), falling back to the adapter repo
|
|
@@ -309,9 +387,102 @@ class ActionPlanner:
|
|
|
309
387
|
# the same key: an adapter reads a puzzle at the pixel band it trained
|
|
310
388
|
# under. Resolved once per planner — see the note above.
|
|
311
389
|
self.pixel_budget = prompts.pixel_budget(_prompt_key)
|
|
390
|
+
# WHICH ADAPTER answers each prompt family. Empty for every model that
|
|
391
|
+
# is not routed, which is every model published so far — `_model_for`
|
|
392
|
+
# then returns `self.model` for every request and the wire is unchanged.
|
|
393
|
+
#
|
|
394
|
+
# Resolved once, like the two above, and for a stronger reason: the pin
|
|
395
|
+
# is validated here, so `--expert grid` against a single-adapter model
|
|
396
|
+
# fails when the solver is BUILT rather than on the first grid it meets.
|
|
397
|
+
self.expert = (expert if expert is not None
|
|
398
|
+
else prompts.expert_pin())
|
|
399
|
+
self.experts = prompts.experts(_prompt_key)
|
|
400
|
+
if self.expert:
|
|
401
|
+
# Raises on an unknown family, and on a pin against an unrouted
|
|
402
|
+
# model. Both are configuration errors, and a benchmark that
|
|
403
|
+
# quietly measured the generalist while reporting an expert is the
|
|
404
|
+
# exact class of silent mispairing models.json exists to prevent.
|
|
405
|
+
prompts.route(_prompt_key, None, pin=self.expert)
|
|
312
406
|
# Auto-start a local vLLM server on the first request if one isn't up
|
|
313
407
|
# (no-op for a healthy or remote endpoint). Guarded so we only try once.
|
|
314
408
|
self._server_ensured = False
|
|
409
|
+
# ONE connection, reused for every round of every solve this planner
|
|
410
|
+
# serves. `requests.post` opens a fresh TCP connection each call — and
|
|
411
|
+
# to a hosted endpoint a TLS handshake with it. Measured against the
|
|
412
|
+
# gate's own endpoint: 258ms per request connectionless vs 144ms
|
|
413
|
+
# pooled, so every inference was paying ~110ms to re-dial a server it
|
|
414
|
+
# had just finished talking to. A multi-round grid pays it per round.
|
|
415
|
+
#
|
|
416
|
+
# The planner is per-PageSolver and a PageSolver is reusable across
|
|
417
|
+
# solves, so a caller who keeps one keeps the connection warm too.
|
|
418
|
+
self._http = requests.Session()
|
|
419
|
+
# Latched off by the first 404 — see `report_outcome`.
|
|
420
|
+
self._outcome_supported = True
|
|
421
|
+
|
|
422
|
+
def _model_for(self, family: Optional[str]) -> str:
|
|
423
|
+
"""The `model` string for a request in `family`.
|
|
424
|
+
|
|
425
|
+
`self.model` whenever the model is not routed, so a caller may pass a
|
|
426
|
+
family unconditionally. A routed model with no expert for this family
|
|
427
|
+
degrades to the generalist — never to another expert, never to an
|
|
428
|
+
error (docs/MOE_LORA_DESIGN.md §11).
|
|
429
|
+
"""
|
|
430
|
+
if not self.experts:
|
|
431
|
+
return self.model
|
|
432
|
+
return prompts.route(self.model, family, pin=self.expert) or self.model
|
|
433
|
+
|
|
434
|
+
# ── the solve's own verdict, sent back ─────────────────────────────────
|
|
435
|
+
#
|
|
436
|
+
# THE ONE THING THE SERVER CANNOT SEE. A wrong answer reaches the gateway as
|
|
437
|
+
# a 200 with well-formed JSON in it; only this driver watched the widget.
|
|
438
|
+
# Without this report the hosted API cannot tell a solved captcha from a
|
|
439
|
+
# failed one, and the failures — the exact boards the model is worst at, on
|
|
440
|
+
# real sites — are the one dataset that cannot be collected any other way.
|
|
441
|
+
#
|
|
442
|
+
# BEST-EFFORT, ALWAYS. It runs after the solve is over and its result is
|
|
443
|
+
# already decided, so nothing it does can change the answer the caller gets.
|
|
444
|
+
# Every failure is swallowed: a self-hosted vLLM has no such route, a
|
|
445
|
+
# network blip is not the caller's problem, and an exception here would turn
|
|
446
|
+
# a successful solve into a raised one.
|
|
447
|
+
#
|
|
448
|
+
# ONE 404 IS ENOUGH. A self-hosted endpoint answers 404 forever, so the
|
|
449
|
+
# first one latches this off for the life of the planner rather than paying
|
|
450
|
+
# a round trip per solve to be told the same thing again.
|
|
451
|
+
_OUTCOME_PATH = "/solve-outcome"
|
|
452
|
+
_OUTCOME_TIMEOUT_S = 3.0
|
|
453
|
+
|
|
454
|
+
def report_outcome(self, session_id: Optional[str], solved: bool) -> bool:
|
|
455
|
+
"""Tell the API whether the widget accepted. True if it was delivered.
|
|
456
|
+
|
|
457
|
+
Never raises. The return value is for tests and for `--debug`; no caller
|
|
458
|
+
should branch on it.
|
|
459
|
+
"""
|
|
460
|
+
if not session_id or not self._outcome_supported:
|
|
461
|
+
return False
|
|
462
|
+
if os.getenv(_REPORT_OUTCOME_ENV, "1") == "0":
|
|
463
|
+
return False
|
|
464
|
+
url = f"{self.base_url}{self._OUTCOME_PATH}"
|
|
465
|
+
try:
|
|
466
|
+
resp = self._http.post(
|
|
467
|
+
url,
|
|
468
|
+
headers={
|
|
469
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
470
|
+
"Content-Type": "application/json",
|
|
471
|
+
},
|
|
472
|
+
json={"session": session_id, "solved": bool(solved)},
|
|
473
|
+
timeout=self._OUTCOME_TIMEOUT_S,
|
|
474
|
+
)
|
|
475
|
+
except Exception as exc: # noqa: BLE001 — see the note above
|
|
476
|
+
self._log(f"outcome report failed: {exc}")
|
|
477
|
+
return False
|
|
478
|
+
if resp.status_code == 404:
|
|
479
|
+
# Not an error: this endpoint does not serve the route. Stop asking.
|
|
480
|
+
self._outcome_supported = False
|
|
481
|
+
self._log("outcome reporting: endpoint has no /solve-outcome; disabled")
|
|
482
|
+
return False
|
|
483
|
+
ok = 200 <= resp.status_code < 300
|
|
484
|
+
self._log(f"outcome report {session_id} solved={solved} -> {resp.status_code}")
|
|
485
|
+
return ok
|
|
315
486
|
|
|
316
487
|
def _log(self, message: str) -> None:
|
|
317
488
|
if DEBUG:
|
|
@@ -319,11 +490,14 @@ class ActionPlanner:
|
|
|
319
490
|
if self.debug_callback:
|
|
320
491
|
self.debug_callback(f"[Planner] {message}")
|
|
321
492
|
|
|
322
|
-
def _chat_with_image(self, prompt: str, image_path: str, max_tokens: int = 512
|
|
323
|
-
|
|
493
|
+
def _chat_with_image(self, prompt: str, image_path: str, max_tokens: int = 512,
|
|
494
|
+
family: Optional[str] = None) -> str:
|
|
495
|
+
return self._chat_with_images(prompt, [image_path], max_tokens=max_tokens,
|
|
496
|
+
family=family)
|
|
324
497
|
|
|
325
498
|
def _chat_with_images(
|
|
326
|
-
self, prompt: str, image_paths: List[str], max_tokens: int = 512
|
|
499
|
+
self, prompt: str, image_paths: List[str], max_tokens: int = 512,
|
|
500
|
+
family: Optional[str] = None,
|
|
327
501
|
) -> str:
|
|
328
502
|
"""One request carrying N images followed by the prompt.
|
|
329
503
|
|
|
@@ -355,8 +529,13 @@ class ActionPlanner:
|
|
|
355
529
|
},
|
|
356
530
|
]
|
|
357
531
|
|
|
532
|
+
model = self._model_for(family)
|
|
533
|
+
# `sampling` is set by the page driver when it is RE-ASKING a board the
|
|
534
|
+
# vendor already refused — see `PageSolverConfig.resample_temperatures`.
|
|
535
|
+
# Absent (the normal case, and every first look) it is greedy exactly as
|
|
536
|
+
# before, so nothing about a solve that works today changes.
|
|
358
537
|
payload = {
|
|
359
|
-
"model":
|
|
538
|
+
"model": model,
|
|
360
539
|
"messages": messages,
|
|
361
540
|
"temperature": 0,
|
|
362
541
|
"max_tokens": max_tokens,
|
|
@@ -365,13 +544,19 @@ class ActionPlanner:
|
|
|
365
544
|
# level is the documented way.
|
|
366
545
|
"chat_template_kwargs": {"enable_thinking": False},
|
|
367
546
|
}
|
|
547
|
+
# In-process the driver sets `sampling` directly; the JS port drives
|
|
548
|
+
# its own loop and spawns this as a CLI, so its level arrives in the
|
|
549
|
+
# environment. Explicit beats inherited when both are present.
|
|
550
|
+
sampling = self.sampling or _sampling_from_env()
|
|
551
|
+
if sampling:
|
|
552
|
+
payload.update(sampling)
|
|
368
553
|
|
|
369
554
|
headers = {
|
|
370
555
|
"Authorization": f"Bearer {self.api_key}",
|
|
371
556
|
"Content-Type": "application/json",
|
|
372
557
|
# Fleet routing: absent unless CAPTCHA_REQUEST_PRIORITY is set (see
|
|
373
558
|
# routing_headers). Low-priority batch traffic is steered to the
|
|
374
|
-
# backup GPUs by the
|
|
559
|
+
# backup GPUs by the gateway front on this header.
|
|
375
560
|
**routing_headers(),
|
|
376
561
|
}
|
|
377
562
|
|
|
@@ -387,11 +572,11 @@ class ActionPlanner:
|
|
|
387
572
|
self._server_ensured = True
|
|
388
573
|
|
|
389
574
|
url = f"{self.base_url}/chat/completions"
|
|
390
|
-
self._log(f"POST {url} model={
|
|
575
|
+
self._log(f"POST {url} model={model} max_tokens={max_tokens} "
|
|
391
576
|
f"images={len(parts)}")
|
|
392
577
|
|
|
393
578
|
with timed("planner.chat"):
|
|
394
|
-
resp =
|
|
579
|
+
resp = self._http.post(url, headers=headers, json=payload, timeout=120)
|
|
395
580
|
|
|
396
581
|
# Surface auth / billing / server errors as something the reader can act
|
|
397
582
|
# on, instead of letting resp.json() blow up with a cryptic "Expecting
|
|
@@ -531,7 +716,8 @@ class ActionPlanner:
|
|
|
531
716
|
"Return the complete list of cell numbers that match the description, "
|
|
532
717
|
"including any matches you may have overlooked."
|
|
533
718
|
)
|
|
534
|
-
raw = self._chat_with_image(prompt, image_path, max_tokens=128
|
|
719
|
+
raw = self._chat_with_image(prompt, image_path, max_tokens=128,
|
|
720
|
+
family="grid")
|
|
535
721
|
out = self._normalize_grid(self._parse_json(raw), total)
|
|
536
722
|
self._log(f"grid selection -> {out}")
|
|
537
723
|
return out
|
|
@@ -592,7 +778,8 @@ class ActionPlanner:
|
|
|
592
778
|
into ClickAction / DragAction / TypeAction.
|
|
593
779
|
"""
|
|
594
780
|
prompt = self.prompts.text_prompt() if text_mode else self.prompts.action_prompt
|
|
595
|
-
raw = self._chat_with_image(prompt, image_path, max_tokens=512
|
|
781
|
+
raw = self._chat_with_image(prompt, image_path, max_tokens=512,
|
|
782
|
+
family="text" if text_mode else "pixel")
|
|
596
783
|
data = self._parse_json(raw)
|
|
597
784
|
actions = self._normalize_pixel(data)
|
|
598
785
|
self._log(f"pixel actions -> {actions}")
|
|
@@ -618,7 +805,8 @@ class ActionPlanner:
|
|
|
618
805
|
# so this raises a clear error there instead of sending a v1 model a
|
|
619
806
|
# keyframe request it was never trained to read.
|
|
620
807
|
prompt = self.prompts.video_prompt(len(keyframe_paths))
|
|
621
|
-
raw = self._chat_with_images(prompt, list(keyframe_paths), max_tokens=512
|
|
808
|
+
raw = self._chat_with_images(prompt, list(keyframe_paths), max_tokens=512,
|
|
809
|
+
family="video")
|
|
622
810
|
data = self._parse_json(raw)
|
|
623
811
|
actions = self._normalize_pixel(data)
|
|
624
812
|
frame = self._normalize_frame(data, len(keyframe_paths))
|
|
@@ -5,7 +5,7 @@ Why this exists
|
|
|
5
5
|
The prompt a captcha model is sent has to be the prompt it was trained on. When
|
|
6
6
|
they drift, nothing errors — the model answers in a schema the client does not
|
|
7
7
|
recognise and puzzles silently fail. That has happened: see failure-mode 3 in
|
|
8
|
-
the
|
|
8
|
+
the training repo's release parity gate, where the shipped prompt
|
|
9
9
|
kept asking for the legacy `output`/`simulate_drag`/PascalCase schema long after
|
|
10
10
|
the LoRA had been retrained on `action: drag`/`drags[]`.
|
|
11
11
|
|
|
@@ -48,14 +48,14 @@ _PROMPTS_FILE_ENV = "CAPTCHA_PROMPTS_FILE"
|
|
|
48
48
|
_DISABLE_FETCH_ENV = "CAPTCHA_PROMPTS_NO_FETCH"
|
|
49
49
|
|
|
50
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
|
|
51
|
+
# send" — that is per model, resolved below. This exists so the training repo
|
|
52
52
|
# can ask "do you speak the generation I am training?" without importing us.
|
|
53
53
|
LATEST_PROMPT_VERSION = "2"
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
# ── built-in templates, one entry per generation in service ─────────────────
|
|
57
57
|
#
|
|
58
|
-
# A PURE LITERAL, deliberately: the
|
|
58
|
+
# A PURE LITERAL, deliberately: the training repo's release parity gate reads
|
|
59
59
|
# it by AST without importing this package (which pulls in pydantic/cv2 that the
|
|
60
60
|
# training venv need not have). Keep it literal — an f-string, a .join(), or a
|
|
61
61
|
# reference to another constant makes it unreadable to that gate, and the gate
|
|
@@ -68,7 +68,7 @@ LATEST_PROMPT_VERSION = "2"
|
|
|
68
68
|
# 2 — adds the PUZZLE PIECE SLIDER clause and the animated + text families.
|
|
69
69
|
# Byte-identical to src/synthetic/reasoning/instructions.py
|
|
70
70
|
# ::PIXEL_INSTRUCTION_TEMPLATE, ::VIDEO_INSTRUCTION_TEMPLATE and
|
|
71
|
-
# ::TEXT_INSTRUCTION in the
|
|
71
|
+
# ::TEXT_INSTRUCTION in the training repo at PROMPT_VERSION 2.
|
|
72
72
|
#
|
|
73
73
|
# Do not edit a published generation's text. Ever. Those models are frozen and
|
|
74
74
|
# so are their prompts; a change here is a change to what an already-shipped
|
|
@@ -247,6 +247,80 @@ def canonical_model_id(model: Optional[str]) -> Optional[str]:
|
|
|
247
247
|
return alias if isinstance(alias, str) else None
|
|
248
248
|
|
|
249
249
|
|
|
250
|
+
# ── availability: can these weights be obtained at all? ─────────────────────
|
|
251
|
+
#
|
|
252
|
+
# Same registry, same reason as prompt_version and pixel_budget: it is a fact
|
|
253
|
+
# about the MODEL, and the one place that already decides what an unpinned
|
|
254
|
+
# client downloads. See models.json's header comment.
|
|
255
|
+
|
|
256
|
+
PUBLIC = "public"
|
|
257
|
+
PRIVATE = "private"
|
|
258
|
+
LICENSED = "licensed"
|
|
259
|
+
#: Anything outside this set is a typo, and a typo must not read as "public".
|
|
260
|
+
#: The whole class of bug this file exists to prevent is a field that fails
|
|
261
|
+
#: open — the release parity gate rejects an unknown value at release time,
|
|
262
|
+
#: and `is_licensed` below treats one as licensed rather than guessing.
|
|
263
|
+
#:
|
|
264
|
+
#: Spelled out as literals rather than `(PUBLIC, PRIVATE, LICENSED)`: the
|
|
265
|
+
#: release gate reads this file by AST so it can run in a training venv with no
|
|
266
|
+
#: client installed, and `ast.literal_eval` cannot follow a name. A tuple of
|
|
267
|
+
#: names reads as "unset" to the gate, which is the check silently not running.
|
|
268
|
+
#: `tests/test_prompt_registry.py` pins that all four constants agree.
|
|
269
|
+
AVAILABILITIES = ("public", "private", "licensed")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def availability(model: Optional[str]) -> str:
|
|
273
|
+
"""`"public"`, `"private"` or `"licensed"` for `model`. Absent means public.
|
|
274
|
+
|
|
275
|
+
The three answer ONE question — can these weights be obtained, and by whom:
|
|
276
|
+
|
|
277
|
+
public on the Hub, by anyone.
|
|
278
|
+
private on the Hub in a PRIVATE repo. The bytes exist and `fetch`
|
|
279
|
+
pulls them, but only for a token this account has authorised.
|
|
280
|
+
Everyone else gets 401/404 from the Hub, which is the right
|
|
281
|
+
answer and not something to pre-empt with a refusal.
|
|
282
|
+
licensed no Hub repo at all. Hosted API or a licence we issue.
|
|
283
|
+
|
|
284
|
+
`private` is the newest and it is NOT a softer `licensed`. They differ in
|
|
285
|
+
what an authorised holder can do, which is the whole reason the field is
|
|
286
|
+
not a boolean: a licensed model has nothing to download however good your
|
|
287
|
+
credentials are, so refusing early is a KINDNESS — it replaces a
|
|
288
|
+
`RepositoryNotFoundError` that reads as "you are not logged in" with the
|
|
289
|
+
truth. Doing the same to a private model would be a lie, and would make our
|
|
290
|
+
own admin token unable to pull weights it is entitled to.
|
|
291
|
+
|
|
292
|
+
An unregistered model is public too: it is not ours, and refusing to
|
|
293
|
+
download a stranger's adapter because we have never heard of it would break
|
|
294
|
+
every self-hoster who trained their own.
|
|
295
|
+
"""
|
|
296
|
+
entry = registered_models().get(canonical_model_id(model) or "") or {}
|
|
297
|
+
value = entry.get("availability")
|
|
298
|
+
return value if isinstance(value, str) and value else PUBLIC
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def is_licensed(model: Optional[str]) -> bool:
|
|
302
|
+
"""True if these weights are NOT OBTAINABLE AT ALL — hosted API or licence.
|
|
303
|
+
|
|
304
|
+
Fails CLOSED on an unrecognised value. A registry that says `"licenced"`
|
|
305
|
+
must not hand out a proprietary model because of a spelling.
|
|
306
|
+
|
|
307
|
+
False for `private`: those weights are downloadable, by the holder of a
|
|
308
|
+
token we authorised. Proprietary and unobtainable are different facts, and
|
|
309
|
+
this function answers the second one — `requires_auth` answers the first.
|
|
310
|
+
"""
|
|
311
|
+
return availability(model) not in (PUBLIC, PRIVATE)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def requires_auth(model: Optional[str]) -> bool:
|
|
315
|
+
"""True if pulling these weights needs a token we authorised.
|
|
316
|
+
|
|
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
|
+
|
|
250
324
|
# ── prompt sets ─────────────────────────────────────────────────────────────
|
|
251
325
|
|
|
252
326
|
@dataclass
|
|
@@ -479,6 +553,114 @@ def pixel_budget(model: Optional[str]) -> PixelBudget:
|
|
|
479
553
|
return DEFAULT_PIXEL_BUDGET
|
|
480
554
|
|
|
481
555
|
|
|
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
|
+
EXPERT_ENV = "CAPTCHA_EXPERT"
|
|
575
|
+
|
|
576
|
+
#: The prompt families, which are also the only keys an `experts` map may
|
|
577
|
+
#: carry. `pixel` is the click/drag family — `PromptSet.action_prompt` — and is
|
|
578
|
+
#: spelled that way rather than `action_pixel` because it is what ckgate's
|
|
579
|
+
#: PROMPT_MARKERS already calls it, and two spellings of one router is how a
|
|
580
|
+
#: family ends up unrouted on one side.
|
|
581
|
+
PROMPT_FAMILIES = ("pixel", "grid", "video", "text")
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def experts(model: Optional[str]) -> Dict[str, str]:
|
|
585
|
+
"""Prompt family -> served adapter name, for a ROUTED model.
|
|
586
|
+
|
|
587
|
+
`{}` for anything that is not routed, which is every model published so
|
|
588
|
+
far. Callers apply it unconditionally; an empty map is a no-op.
|
|
589
|
+
|
|
590
|
+
Unknown family keys are DROPPED rather than passed through. A typo in the
|
|
591
|
+
registry would otherwise be a name nothing ever selects — invisible,
|
|
592
|
+
because the fallback below is a working solve at a slightly worse score,
|
|
593
|
+
which is the exact failure mode this whole module exists to prevent.
|
|
594
|
+
"""
|
|
595
|
+
repo_id = canonical_model_id(model)
|
|
596
|
+
entry = (registered_models().get(repo_id) or {}) if repo_id else {}
|
|
597
|
+
mapping = entry.get("experts")
|
|
598
|
+
if not isinstance(mapping, dict):
|
|
599
|
+
return {}
|
|
600
|
+
out = {}
|
|
601
|
+
for family in PROMPT_FAMILIES:
|
|
602
|
+
name = mapping.get(family)
|
|
603
|
+
if isinstance(name, str) and name:
|
|
604
|
+
out[family] = name
|
|
605
|
+
for key in mapping:
|
|
606
|
+
if key not in PROMPT_FAMILIES and not str(key).startswith("_"):
|
|
607
|
+
_warn(f"model {repo_id!r} declares an expert for unknown prompt "
|
|
608
|
+
f"family {key!r}; have {', '.join(PROMPT_FAMILIES)}. It will "
|
|
609
|
+
"never be selected.")
|
|
610
|
+
return out
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def route(model: Optional[str], family: Optional[str], *,
|
|
614
|
+
pin: Optional[str] = None) -> str:
|
|
615
|
+
"""The `model` string to put on the wire for one request.
|
|
616
|
+
|
|
617
|
+
`model` is what the caller would have sent — a served name or a repo id —
|
|
618
|
+
and is returned unchanged whenever the model is not routed, which is what
|
|
619
|
+
keeps every existing deployment byte-identical.
|
|
620
|
+
|
|
621
|
+
`pin` forces one family for every request, whatever `family` says. That is
|
|
622
|
+
what a gate wants: serve one arm, grade the types it owns, serve the next.
|
|
623
|
+
An unknown `pin` RAISES, because a benchmark that silently measured the
|
|
624
|
+
generalist and reported it as the expert is a number nobody can catch.
|
|
625
|
+
|
|
626
|
+
An unrecognised or unmapped `family` degrades to `model` — the generalist —
|
|
627
|
+
never to another expert and never to an error. A caller prompting in their
|
|
628
|
+
own words is the expected case for a shipped model, and the day the
|
|
629
|
+
generation-2 `text` family reached ckgate without a marker, refusing it cost
|
|
630
|
+
every distorted-text solve in production for a day.
|
|
631
|
+
"""
|
|
632
|
+
if pin is not None:
|
|
633
|
+
pin = pin.strip()
|
|
634
|
+
if pin:
|
|
635
|
+
if pin not in PROMPT_FAMILIES:
|
|
636
|
+
raise ValueError(
|
|
637
|
+
f"unknown expert {pin!r}; have {', '.join(PROMPT_FAMILIES)}")
|
|
638
|
+
family = pin
|
|
639
|
+
mapping = experts(model)
|
|
640
|
+
if not mapping:
|
|
641
|
+
if pin:
|
|
642
|
+
raise ValueError(
|
|
643
|
+
f"expert {pin!r} was requested but model {model!r} declares no "
|
|
644
|
+
"experts — it serves one adapter under one name. Drop the "
|
|
645
|
+
"expert, or point the client at a routed model.")
|
|
646
|
+
return model or ""
|
|
647
|
+
return mapping.get(family or "") or model or ""
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _env_str(name: str) -> Optional[str]:
|
|
651
|
+
"""Like `_env_int`, and read through the same indirection for the same
|
|
652
|
+
second reason: `test_public_contract.py` finds environment variables by
|
|
653
|
+
grepping for `os.environ.get(<NAME>)`, so naming the CONSTANT there records
|
|
654
|
+
`EXPERT_ENV` in the published env list as though it were a variable anyone
|
|
655
|
+
could set. The literal is picked up from the assignment above instead."""
|
|
656
|
+
return (os.environ.get(name) or "").strip() or None
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def expert_pin() -> Optional[str]:
|
|
660
|
+
"""`CAPTCHA_EXPERT`, or None. Empty and unset are the same thing."""
|
|
661
|
+
return _env_str(EXPERT_ENV)
|
|
662
|
+
|
|
663
|
+
|
|
482
664
|
def clear_cache() -> None:
|
|
483
665
|
_cache.clear()
|
|
484
666
|
global _REGISTRY
|
|
@@ -170,11 +170,16 @@ def ensure_server(base_url: "str | None" = None) -> None:
|
|
|
170
170
|
"""
|
|
171
171
|
base_url = base_url or config.base_url()
|
|
172
172
|
|
|
173
|
-
|
|
174
|
-
|
|
173
|
+
# REMOTE FIRST. There is nothing to ensure about a server we do not manage,
|
|
174
|
+
# so asking it for /health before deciding that is a round trip spent to
|
|
175
|
+
# reach a `return` — and up to `timeout=2.0` of it against a hosted gateway
|
|
176
|
+
# that serves no /health at all. Paid once per ActionPlanner, which for a
|
|
177
|
+
# caller using `solve_captcha_on_page` is once per solve.
|
|
175
178
|
if not is_local(base_url):
|
|
176
|
-
#
|
|
177
|
-
#
|
|
179
|
+
# Let the actual request raise a clear connection error rather than
|
|
180
|
+
# silently trying to boot vLLM locally.
|
|
181
|
+
return
|
|
182
|
+
if is_healthy(base_url):
|
|
178
183
|
return
|
|
179
184
|
if not config.autostart_enabled():
|
|
180
185
|
return
|