captchakraken 2.2.0 → 2.4.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 +105 -0
- package/README.md +3 -1
- package/dist/errors.d.ts +45 -0
- package/dist/errors.js +74 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +24 -1
- package/dist/playwright-types.d.ts +2 -0
- package/dist/solver.d.ts +67 -0
- package/dist/solver.js +389 -15
- package/dist/types.d.ts +103 -0
- package/package.json +4 -3
- package/python/LICENSE +105 -0
- package/python/README.md +3 -1
- package/python/pyproject.toml +6 -2
- package/python/src/captchakraken/__init__.py +13 -1
- package/python/src/captchakraken/cli.py +75 -0
- package/python/src/captchakraken/config.py +156 -6
- package/python/src/captchakraken/errors.py +195 -0
- package/python/src/captchakraken/page_solver.py +1579 -0
- package/python/src/captchakraken/pinned_model.json +28 -0
- package/python/src/captchakraken/planner.py +324 -32
- package/python/src/captchakraken/server_manager.py +3 -2
- package/python/src/captchakraken/trajectory.py +172 -0
- package/python/src/captchakraken/updater.py +168 -0
- package/scripts/setup-python.js +16 -2
package/python/LICENSE
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
CaptchaKraken Source-Available License v1.0
|
|
2
|
+
Copyright (c) 2026 JobHarvest / JWriter20. All rights reserved.
|
|
3
|
+
|
|
4
|
+
This license governs use of the CaptchaKraken software in this repository and of
|
|
5
|
+
the associated machine-learning model weights and LoRA adapters published by the
|
|
6
|
+
copyright holder (collectively, the "Software"). By using the Software you agree
|
|
7
|
+
to these terms. If you do not agree, do not use the Software.
|
|
8
|
+
|
|
9
|
+
================================================================================
|
|
10
|
+
1. GRANT
|
|
11
|
+
================================================================================
|
|
12
|
+
Subject to the restrictions below, you are granted a worldwide, royalty-free,
|
|
13
|
+
non-exclusive license to use, copy, modify, and distribute the Software, and to
|
|
14
|
+
incorporate it into your own products and services.
|
|
15
|
+
|
|
16
|
+
================================================================================
|
|
17
|
+
2. PERMITTED USES
|
|
18
|
+
================================================================================
|
|
19
|
+
You MAY use the Software for:
|
|
20
|
+
|
|
21
|
+
(a) Personal, non-commercial use, research, and education.
|
|
22
|
+
|
|
23
|
+
(b) Commercial use INSIDE a larger product or service that delivers
|
|
24
|
+
substantial value BEYOND captcha solving itself — that is, where captcha
|
|
25
|
+
solving is an internal, enabling component rather than the product.
|
|
26
|
+
|
|
27
|
+
Illustrative (non-exhaustive) examples of PERMITTED commercial use:
|
|
28
|
+
- Web scrapers and data-collection pipelines.
|
|
29
|
+
- Stealth / anti-detection browsers and browser automation frameworks
|
|
30
|
+
that use the Software as one internal capability.
|
|
31
|
+
- QA, testing, and accessibility tooling.
|
|
32
|
+
- Any application where the captcha solve is a means to an end the user
|
|
33
|
+
is actually paying for.
|
|
34
|
+
|
|
35
|
+
================================================================================
|
|
36
|
+
3. PROHIBITED USES
|
|
37
|
+
================================================================================
|
|
38
|
+
You MAY NOT, without a separate written commercial agreement from the copyright
|
|
39
|
+
holder:
|
|
40
|
+
|
|
41
|
+
(a) Sell, resell, rent, or otherwise offer for a fee any captcha-SOLVING
|
|
42
|
+
service, product, or API whose primary value is solving captchas,
|
|
43
|
+
where that service is powered by the Software or its model outputs.
|
|
44
|
+
|
|
45
|
+
(b) Distribute "thin wrappers" around the Software whose primary purpose is
|
|
46
|
+
captcha solving — including but not limited to browser extensions,
|
|
47
|
+
hosted endpoints, SaaS products, or CLIs that simply expose the
|
|
48
|
+
Software's solving capability to end users.
|
|
49
|
+
|
|
50
|
+
(c) Expose, proxy, or relay the responses or outputs of the Software's LoRA
|
|
51
|
+
models (e.g. bounding boxes, tile selections, click plans) through a paid
|
|
52
|
+
or public API as a captcha-solving service.
|
|
53
|
+
|
|
54
|
+
In short: you may BUILD WITH this Software, but you may not SELL THE SOLVE.
|
|
55
|
+
|
|
56
|
+
================================================================================
|
|
57
|
+
4. MODEL OUTPUTS
|
|
58
|
+
================================================================================
|
|
59
|
+
Outputs produced by the model weights/adapters are subject to Sections 2 and 3
|
|
60
|
+
to the same extent as the Software itself. Reselling or API-relaying those
|
|
61
|
+
outputs as a captcha-solving service is prohibited under Section 3(c).
|
|
62
|
+
|
|
63
|
+
================================================================================
|
|
64
|
+
5. REDISTRIBUTION
|
|
65
|
+
================================================================================
|
|
66
|
+
If you redistribute the Software (modified or not), you must retain this license
|
|
67
|
+
and this notice, and you must not remove or alter the restrictions in Section 3.
|
|
68
|
+
|
|
69
|
+
================================================================================
|
|
70
|
+
6. TRADEMARKS
|
|
71
|
+
================================================================================
|
|
72
|
+
This license does not grant permission to use the trade names, trademarks, or
|
|
73
|
+
product names of the copyright holder, except as required to describe the origin
|
|
74
|
+
of the Software.
|
|
75
|
+
|
|
76
|
+
================================================================================
|
|
77
|
+
7. NO WARRANTY
|
|
78
|
+
================================================================================
|
|
79
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
80
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
81
|
+
FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
82
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER
|
|
83
|
+
IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
|
|
84
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
85
|
+
|
|
86
|
+
================================================================================
|
|
87
|
+
8. RESPONSIBLE USE
|
|
88
|
+
================================================================================
|
|
89
|
+
You are responsible for ensuring your use of the Software complies with all
|
|
90
|
+
applicable laws, the terms of service of any site you interact with, and
|
|
91
|
+
applicable anti-fraud and computer-access statutes. The copyright holder does
|
|
92
|
+
not endorse using the Software to violate any third party's terms or rights.
|
|
93
|
+
|
|
94
|
+
================================================================================
|
|
95
|
+
9. COMMERCIAL LICENSING
|
|
96
|
+
================================================================================
|
|
97
|
+
For uses prohibited under Section 3 — including selling captcha-solving as a
|
|
98
|
+
service — contact the copyright holder to discuss a separate commercial license.
|
|
99
|
+
|
|
100
|
+
--------------------------------------------------------------------------------
|
|
101
|
+
This text is a license, not legal advice. If the boundary between a permitted
|
|
102
|
+
"value-add" product and a prohibited "thin wrapper" is unclear for your use
|
|
103
|
+
case, ask by opening an issue or messaging the maintainer on GitHub:
|
|
104
|
+
https://github.com/JWriter20/CaptchaKraken
|
|
105
|
+
--------------------------------------------------------------------------------
|
package/python/README.md
CHANGED
|
@@ -68,4 +68,6 @@ Everything model-specific lives in `captchakraken.config` and is env-overridable
|
|
|
68
68
|
|
|
69
69
|
## License
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
**CaptchaKraken Source-Available License v1.0** — see [LICENSE](./LICENSE).
|
|
72
|
+
Build *with* it (scrapers, stealth browsers, QA tooling); you may **not sell the
|
|
73
|
+
solve itself** or ship a thin wrapper (browser extension, hosted solving API).
|
package/python/pyproject.toml
CHANGED
|
@@ -4,17 +4,21 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "captchakraken"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.4.0"
|
|
8
8
|
description = "Self-hosted captcha solver: OpenCV grid detection + a fine-tuned Qwen3.5-9B vision LoRA served on vLLM."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
|
-
|
|
11
|
+
# Source-available, NOT GPL. The CaptchaKraken Source-Available License permits
|
|
12
|
+
# commercial use as an internal component but prohibits selling the solve itself
|
|
13
|
+
# or thin-wrapper resale — see LICENSE. (GPL-3.0 would wrongly permit reselling.)
|
|
14
|
+
license = { file = "LICENSE" }
|
|
12
15
|
authors = [{ name = "Jake Writer" }]
|
|
13
16
|
keywords = ["captcha", "recaptcha", "hcaptcha", "vllm", "qwen", "computer-vision", "automation"]
|
|
14
17
|
classifiers = [
|
|
15
18
|
"Programming Language :: Python :: 3",
|
|
16
19
|
"Operating System :: OS Independent",
|
|
17
20
|
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
21
|
+
"License :: Other/Proprietary License",
|
|
18
22
|
]
|
|
19
23
|
|
|
20
24
|
# Core = the lightweight CLIENT: OpenCV grid detection + the HTTP planner that
|
|
@@ -40,14 +40,26 @@ from .overlay import add_overlays_to_image
|
|
|
40
40
|
try: # pragma: no cover - exercised only when the serving stack is installed
|
|
41
41
|
from .planner import ActionPlanner
|
|
42
42
|
from .solver import CaptchaSolver, solve_captcha
|
|
43
|
+
|
|
44
|
+
# The page driver sits behind the same guard: it imports `solver`, so it has
|
|
45
|
+
# the same dependency floor. It needs NO browser package of its own — the
|
|
46
|
+
# caller supplies the Playwright-compatible page (see page_solver's module
|
|
47
|
+
# docstring on why we duck-type rather than import one).
|
|
48
|
+
from .page_solver import PageSolver, SolveResult, solve_captcha_on_page
|
|
43
49
|
except ModuleNotFoundError:
|
|
44
50
|
ActionPlanner = None # type: ignore[assignment,misc]
|
|
45
51
|
CaptchaSolver = None # type: ignore[assignment,misc]
|
|
46
52
|
solve_captcha = None # type: ignore[assignment]
|
|
53
|
+
PageSolver = None # type: ignore[assignment,misc]
|
|
54
|
+
SolveResult = None # type: ignore[assignment,misc]
|
|
55
|
+
solve_captcha_on_page = None # type: ignore[assignment]
|
|
47
56
|
|
|
48
57
|
__all__ = [
|
|
49
58
|
"CaptchaSolver",
|
|
50
59
|
"solve_captcha",
|
|
60
|
+
"PageSolver",
|
|
61
|
+
"SolveResult",
|
|
62
|
+
"solve_captcha_on_page",
|
|
51
63
|
"ActionPlanner",
|
|
52
64
|
"ImageProcessor",
|
|
53
65
|
"CaptchaAction",
|
|
@@ -58,4 +70,4 @@ __all__ = [
|
|
|
58
70
|
"add_overlays_to_image",
|
|
59
71
|
]
|
|
60
72
|
|
|
61
|
-
__version__ = "2.
|
|
73
|
+
__version__ = "2.3.0"
|
|
@@ -42,13 +42,21 @@ Modes:
|
|
|
42
42
|
these — a local server auto-starts on the first solve (disable with
|
|
43
43
|
CAPTCHA_KRAKEN_AUTOSTART=0). Point VLLM_BASE_URL at your own server to
|
|
44
44
|
skip local management entirely.
|
|
45
|
+
|
|
46
|
+
captchakraken fetch [--weights-only|--engine-only] [--no-restart] [--dry-run]
|
|
47
|
+
Update in one step: pull the latest CaptchaKraken model from the HF org
|
|
48
|
+
(https://huggingface.co/CaptchaKraken) AND upgrade the vLLM serving
|
|
49
|
+
stack, then restart a running local server so it takes effect. Use this
|
|
50
|
+
to get new model revisions + engine fixes without re-running setup.sh.
|
|
45
51
|
"""
|
|
46
52
|
|
|
47
53
|
import argparse
|
|
48
54
|
import json
|
|
49
55
|
import os
|
|
56
|
+
import subprocess
|
|
50
57
|
import sys
|
|
51
58
|
|
|
59
|
+
from .errors import CaptchaKrakenAPIError
|
|
52
60
|
from .solver import CaptchaSolver, UnsupportedCaptchaError
|
|
53
61
|
from .timing import timed
|
|
54
62
|
|
|
@@ -372,6 +380,7 @@ def _handle_serve() -> bool:
|
|
|
372
380
|
find-grid {image} -> grid_boxes | null
|
|
373
381
|
grid-cell-states {a, b} -> states | {grid: null}
|
|
374
382
|
grid-cell-states-fixed {a, b, grid_boxes} -> states
|
|
383
|
+
check-movement {a, b, threshold?} -> {has_movement: bool}
|
|
375
384
|
|
|
376
385
|
Unknown cmd / malformed line -> an {ok:false} response (the process keeps
|
|
377
386
|
running). EOF on stdin ends the loop cleanly. All heavy detection delegates
|
|
@@ -396,6 +405,15 @@ def _handle_serve() -> bool:
|
|
|
396
405
|
if not grid_boxes:
|
|
397
406
|
raise ValueError("empty grid_boxes")
|
|
398
407
|
return _compute_grid_cell_states(req["a"], req["b"], grid_boxes)
|
|
408
|
+
if cmd == "check-movement":
|
|
409
|
+
# Freshness check for the JS solver: did the captcha frame change
|
|
410
|
+
# (tiles faded in / refreshed) between the screenshot we sent the
|
|
411
|
+
# model and now? Same primitive the one-shot `check-movement` uses.
|
|
412
|
+
from .image_processor import ImageProcessor
|
|
413
|
+
|
|
414
|
+
threshold = float(req.get("threshold", 0.005))
|
|
415
|
+
moved = ImageProcessor.detect_movement(req["a"], req["b"], threshold)
|
|
416
|
+
return {"has_movement": bool(moved)}
|
|
399
417
|
raise ValueError(f"unknown cmd: {cmd!r}")
|
|
400
418
|
|
|
401
419
|
# Signal readiness so the JS side knows imports are done before it polls.
|
|
@@ -560,9 +578,55 @@ def _handle_server_commands() -> bool:
|
|
|
560
578
|
return True
|
|
561
579
|
|
|
562
580
|
|
|
581
|
+
def _handle_fetch() -> bool:
|
|
582
|
+
"""`captchakraken fetch` — pull the latest published weights from the HF org
|
|
583
|
+
and refresh the vLLM serving stack, restarting a running local server so the
|
|
584
|
+
update takes effect. See updater.fetch for the full behavior."""
|
|
585
|
+
if len(sys.argv) <= 1 or sys.argv[1] not in {"fetch", "update"}:
|
|
586
|
+
return False
|
|
587
|
+
|
|
588
|
+
flags = set(sys.argv[2:])
|
|
589
|
+
unknown = flags - {"--weights-only", "--engine-only", "--no-restart", "--dry-run"}
|
|
590
|
+
if unknown:
|
|
591
|
+
print(
|
|
592
|
+
json.dumps({"error": f"unknown fetch flag(s): {sorted(unknown)} "
|
|
593
|
+
"(use --weights-only|--engine-only|--no-restart|--dry-run)"}),
|
|
594
|
+
file=sys.stderr,
|
|
595
|
+
)
|
|
596
|
+
sys.exit(2)
|
|
597
|
+
if "--weights-only" in flags and "--engine-only" in flags:
|
|
598
|
+
print(json.dumps({"error": "--weights-only and --engine-only are mutually exclusive"}),
|
|
599
|
+
file=sys.stderr)
|
|
600
|
+
sys.exit(2)
|
|
601
|
+
|
|
602
|
+
from . import updater
|
|
603
|
+
|
|
604
|
+
weights = "--engine-only" not in flags
|
|
605
|
+
engine = "--weights-only" not in flags
|
|
606
|
+
try:
|
|
607
|
+
result = updater.fetch(
|
|
608
|
+
weights=weights,
|
|
609
|
+
engine=engine,
|
|
610
|
+
restart="--no-restart" not in flags,
|
|
611
|
+
dry_run="--dry-run" in flags,
|
|
612
|
+
)
|
|
613
|
+
print(json.dumps(result))
|
|
614
|
+
return True
|
|
615
|
+
except subprocess.CalledProcessError as e:
|
|
616
|
+
print(json.dumps({"error": f"fetch step failed (exit {e.returncode}): "
|
|
617
|
+
f"{' '.join(e.cmd) if isinstance(e.cmd, list) else e.cmd}"}),
|
|
618
|
+
file=sys.stderr)
|
|
619
|
+
sys.exit(1)
|
|
620
|
+
except Exception as e:
|
|
621
|
+
print(json.dumps({"error": str(e)}), file=sys.stderr)
|
|
622
|
+
sys.exit(1)
|
|
623
|
+
|
|
624
|
+
|
|
563
625
|
def main():
|
|
564
626
|
if _handle_server_commands():
|
|
565
627
|
return
|
|
628
|
+
if _handle_fetch():
|
|
629
|
+
return
|
|
566
630
|
if _handle_movement_commands():
|
|
567
631
|
return
|
|
568
632
|
if _handle_move_commands():
|
|
@@ -644,6 +708,17 @@ def main():
|
|
|
644
708
|
# checkboxes. Emit a clean error with no traceback.
|
|
645
709
|
print(json.dumps({"error": str(e), "unsupported": True}), file=sys.stderr)
|
|
646
710
|
sys.exit(2)
|
|
711
|
+
except CaptchaKrakenAPIError as e:
|
|
712
|
+
# The hosted API refused us and told us why. That is an answer, not a
|
|
713
|
+
# crash, so no traceback: a stack trace here buries the one line the
|
|
714
|
+
# user needs ("out of credits, top up at …") under frames from a module
|
|
715
|
+
# they have never opened.
|
|
716
|
+
#
|
|
717
|
+
# Exit 3, distinct from 1 (genuine failure) and 2 (unsupported puzzle),
|
|
718
|
+
# and the payload carries the machine-readable fields so the JS driver
|
|
719
|
+
# can rebuild the error rather than regex it out of prose.
|
|
720
|
+
print(json.dumps(e.to_payload()), file=sys.stderr)
|
|
721
|
+
sys.exit(3)
|
|
647
722
|
except Exception as e:
|
|
648
723
|
import traceback
|
|
649
724
|
|
|
@@ -13,37 +13,187 @@ The two variables MOST users ever set:
|
|
|
13
13
|
Advanced / self-hosting overrides (only needed to change WHICH model is served):
|
|
14
14
|
CAPTCHA_BASE_MODEL base weights vLLM loads (HF id or local path)
|
|
15
15
|
CAPTCHA_LORA_ADAPTER LoRA applied on top (HF id or local path)
|
|
16
|
+
CAPTCHA_LORA_REVISION adapter git revision to serve
|
|
16
17
|
CAPTCHA_LORA_NAME served adapter name the client asks for as `model`
|
|
18
|
+
|
|
19
|
+
Defaults for the model-identity values come from `pinned_model.json` (see
|
|
20
|
+
`pinned()`), which records the model + prompt pair this release was validated
|
|
21
|
+
against.
|
|
17
22
|
VLLM_PORT, VLLM_GPU_MEMORY_UTILIZATION, VLLM_MAX_MODEL_LEN, VLLM_MAX_LORA_RANK
|
|
18
23
|
CAPTCHA_KRAKEN_AUTOSTART=0 disable auto-starting a local server on first use
|
|
19
24
|
"""
|
|
20
25
|
|
|
26
|
+
import json
|
|
21
27
|
import os
|
|
28
|
+
from functools import lru_cache
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Dict
|
|
31
|
+
|
|
32
|
+
_PINNED_MODEL_PATH = Path(__file__).with_name("pinned_model.json")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@lru_cache(maxsize=1)
|
|
36
|
+
def pinned() -> Dict[str, Any]:
|
|
37
|
+
"""The pinned model manifest shipped with this release.
|
|
38
|
+
|
|
39
|
+
Defines the (base model, adapter, serving prompts) triple this version was
|
|
40
|
+
validated against. Env vars still override every value below — the manifest
|
|
41
|
+
supplies the DEFAULT and gives CI something to assert against, so an adapter
|
|
42
|
+
or prompt change is a reviewable diff instead of a silent drift.
|
|
43
|
+
"""
|
|
44
|
+
with _PINNED_MODEL_PATH.open(encoding="utf-8") as f:
|
|
45
|
+
return json.load(f)
|
|
22
46
|
|
|
23
47
|
|
|
24
48
|
# ── Inference endpoint ──────────────────────────────────────────────────────
|
|
25
49
|
def base_url() -> str:
|
|
26
|
-
"""OpenAI-compatible endpoint, e.g. http://localhost:8000/v1.
|
|
27
|
-
|
|
50
|
+
"""OpenAI-compatible endpoint, e.g. http://localhost:8000/v1.
|
|
51
|
+
|
|
52
|
+
Precedence mirrors `api_key()` exactly — env, then the credentials file,
|
|
53
|
+
then the local default — because the two values are set together and a
|
|
54
|
+
reader who has learned one ordering should not have to learn a second.
|
|
55
|
+
|
|
56
|
+
1. VLLM_BASE_URL. An explicit override always wins; self-hosters and the
|
|
57
|
+
dev environment depend on this and must not be silently redirected.
|
|
58
|
+
2. The credentials file. Written by the MCP signup flow alongside the key,
|
|
59
|
+
so onboarding configures the endpoint in the same step and a hosted
|
|
60
|
+
user needs no env plumbing at all.
|
|
61
|
+
3. localhost. Unchanged, and still correct: someone with no credentials
|
|
62
|
+
file is by definition not a hosted user, so the self-hosted default is
|
|
63
|
+
the right guess for them.
|
|
64
|
+
|
|
65
|
+
The bug this closes: a camoufox user who signed up through the MCP had a
|
|
66
|
+
valid key and no endpoint, so every solve dialled a local port with nothing
|
|
67
|
+
behind it and failed with connection-refused — a message that says nothing
|
|
68
|
+
about the fact that their requests were meant to go to api.captchakraken.com.
|
|
69
|
+
"""
|
|
70
|
+
return (
|
|
71
|
+
os.getenv("VLLM_BASE_URL")
|
|
72
|
+
or _base_url_from_credentials_file()
|
|
73
|
+
or f"http://localhost:{port()}/v1"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def state_dir() -> Path:
|
|
78
|
+
"""Shared state dir for the pidfile, lockfile, server log, and credentials."""
|
|
79
|
+
return Path(os.getenv("CAPTCHA_KRAKEN_STATE_DIR", str(Path.home() / ".captchakraken")))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def credentials_path() -> Path:
|
|
83
|
+
return state_dir() / "credentials"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# Names the credentials file may use for each value. Two spellings each, because
|
|
87
|
+
# the file doubles as something a user can `source` — the VLLM_* names are the
|
|
88
|
+
# env vars this module already honours, and the CAPTCHA_KRAKEN_* names are what
|
|
89
|
+
# the product calls them. Accepting both costs nothing and spares anyone the
|
|
90
|
+
# discovery that they picked the wrong one.
|
|
91
|
+
_KEY_NAMES = ("CAPTCHA_KRAKEN_API_KEY", "VLLM_API_KEY")
|
|
92
|
+
_BASE_URL_NAMES = ("CAPTCHA_KRAKEN_BASE_URL", "VLLM_BASE_URL")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _read_credentials_file() -> Dict[str, str]:
|
|
96
|
+
"""Parse the file the MCP server's signup flow writes.
|
|
97
|
+
|
|
98
|
+
Hosted-API onboarding writes the key to a 0600 file rather than returning it
|
|
99
|
+
through the agent transcript, so it never lands in an LLM context window.
|
|
100
|
+
Reading it here is what lets a solve authenticate with NO env vars set.
|
|
101
|
+
|
|
102
|
+
Returns a mapping of recognised name -> value. The special key ``""`` holds a
|
|
103
|
+
BARE TOKEN — a file containing nothing but `ck_live_…`, which is what the
|
|
104
|
+
original single-value format was and what a user hand-writing this file is
|
|
105
|
+
most likely to produce. Dropping that would silently break every existing
|
|
106
|
+
credentials file, so it is still accepted and still means "the API key".
|
|
107
|
+
|
|
108
|
+
Deliberately tolerant of quoting and comments: the file is written by a
|
|
109
|
+
*separate* tool, and a format mismatch here surfaces as a baffling 401
|
|
110
|
+
rather than an obvious parse error. Returns {} whenever the file is missing
|
|
111
|
+
or unreadable — the self-hosted case, which must stay silent.
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
text = credentials_path().read_text(encoding="utf-8")
|
|
115
|
+
except OSError:
|
|
116
|
+
return {}
|
|
117
|
+
|
|
118
|
+
found: Dict[str, str] = {}
|
|
119
|
+
for line in text.splitlines():
|
|
120
|
+
line = line.strip()
|
|
121
|
+
if not line or line.startswith("#"):
|
|
122
|
+
continue
|
|
123
|
+
if "=" in line:
|
|
124
|
+
name, _, value = line.partition("=")
|
|
125
|
+
name = name.strip()
|
|
126
|
+
# An env-file with unrelated keys must not yield a bogus token.
|
|
127
|
+
if name not in _KEY_NAMES and name not in _BASE_URL_NAMES:
|
|
128
|
+
continue
|
|
129
|
+
found.setdefault(name, value.strip().strip("'\""))
|
|
130
|
+
elif "" not in found:
|
|
131
|
+
# A bare token. Only the first such line counts; later prose in a
|
|
132
|
+
# hand-edited file must not overwrite the credential.
|
|
133
|
+
found[""] = line.strip("'\"")
|
|
134
|
+
|
|
135
|
+
return found
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _first_present(values: Dict[str, str], names) -> str:
|
|
139
|
+
"""First non-empty value among `names`, in the order given."""
|
|
140
|
+
for name in names:
|
|
141
|
+
value = values.get(name, "").strip()
|
|
142
|
+
if value:
|
|
143
|
+
return value
|
|
144
|
+
return ""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _key_from_credentials_file() -> str:
|
|
148
|
+
"""The API key from the credentials file, or "" if there isn't one."""
|
|
149
|
+
values = _read_credentials_file()
|
|
150
|
+
return _first_present(values, _KEY_NAMES) or values.get("", "").strip()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _base_url_from_credentials_file() -> str:
|
|
154
|
+
"""The endpoint from the credentials file, or "" if it names none.
|
|
155
|
+
|
|
156
|
+
A bare-token file deliberately yields nothing here: it carries no endpoint,
|
|
157
|
+
and inventing the hosted one for it would silently redirect a self-hoster
|
|
158
|
+
who wrote a token into that file by hand.
|
|
159
|
+
"""
|
|
160
|
+
return _first_present(_read_credentials_file(), _BASE_URL_NAMES)
|
|
28
161
|
|
|
29
162
|
|
|
30
163
|
def api_key() -> str:
|
|
31
|
-
|
|
164
|
+
"""Bearer token, in precedence order.
|
|
165
|
+
|
|
166
|
+
Env first so an explicit override always wins, then the credentials file so
|
|
167
|
+
the MCP/hosted path needs no env plumbing at all, then "EMPTY" (which leaves
|
|
168
|
+
a self-hosted local vLLM open, as before).
|
|
169
|
+
"""
|
|
170
|
+
return (
|
|
171
|
+
os.getenv("CAPTCHA_KRAKEN_API_KEY")
|
|
172
|
+
or os.getenv("VLLM_API_KEY")
|
|
173
|
+
or _key_from_credentials_file()
|
|
174
|
+
or "EMPTY"
|
|
175
|
+
)
|
|
32
176
|
|
|
33
177
|
|
|
34
178
|
# ── Model identity (all overridable — the CLI itself is model-agnostic) ──────
|
|
35
179
|
def base_model() -> str:
|
|
36
|
-
return os.getenv("CAPTCHA_BASE_MODEL", "
|
|
180
|
+
return os.getenv("CAPTCHA_BASE_MODEL", pinned()["base_model"])
|
|
37
181
|
|
|
38
182
|
|
|
39
183
|
def lora_adapter() -> str:
|
|
40
184
|
"""HF repo id or local path of the captcha adapter served on top of the base."""
|
|
41
|
-
return os.getenv("CAPTCHA_LORA_ADAPTER", "
|
|
185
|
+
return os.getenv("CAPTCHA_LORA_ADAPTER", pinned()["lora_adapter"])
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def lora_revision() -> str:
|
|
189
|
+
"""Git revision of the adapter repo to serve — pinning this is what makes
|
|
190
|
+
'the adapter this release was tested against' a reproducible statement."""
|
|
191
|
+
return os.getenv("CAPTCHA_LORA_REVISION", pinned()["lora_revision"])
|
|
42
192
|
|
|
43
193
|
|
|
44
194
|
def lora_name() -> str:
|
|
45
195
|
"""The served LoRA name the client sends as the `model` field."""
|
|
46
|
-
return os.getenv("CAPTCHA_LORA_NAME", "
|
|
196
|
+
return os.getenv("CAPTCHA_LORA_NAME", pinned()["lora_name"])
|
|
47
197
|
|
|
48
198
|
|
|
49
199
|
# ── Local vLLM server knobs ─────────────────────────────────────────────────
|